<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://pyth3rex.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://pyth3rex.github.io/" rel="alternate" type="text/html" /><updated>2026-04-26T20:50:34+00:00</updated><id>https://pyth3rex.github.io/feed.xml</id><title type="html">Pyth3rEx</title><subtitle>Security research, red teaming, reconnaissance, and operational notes by Pyth3rEx. Covering adversarial tooling, infrastructure, and OSINT.</subtitle><author><name>Pyth3rEx</name></author><entry><title type="html">PRAVDA - Your Bank’s Chatbot Is Repeating Kremlin Lies</title><link href="https://pyth3rex.github.io/blog/2026/04/26/pravda-bank-chatbot-russian-lies/" rel="alternate" type="text/html" title="PRAVDA - Your Bank’s Chatbot Is Repeating Kremlin Lies" /><published>2026-04-26T00:00:00+00:00</published><updated>2026-04-26T00:00:00+00:00</updated><id>https://pyth3rex.github.io/blog/2026/04/26/pravda-bank-chatbot-russian-lies</id><content type="html" xml:base="https://pyth3rex.github.io/blog/2026/04/26/pravda-bank-chatbot-russian-lies/"><![CDATA[<!-- markdownlint-disable MD013 -->

<p>You’ve never visited a Russian propaganda site. You’ve never clicked a suspicious link. But every time you chat with your bank’s AI assistant, you might be getting served Kremlin talking points — and neither you nor the bank has any idea.</p>

<hr />

<h1 id="the-weight-of-truth">The weight of truth</h1>

<p>We all use AI today. On ChatGPT, Gemini or DeepSeek; but also in our cars, banking apps, or even microwaves. Yet we rarely stop to ask: what does it consider true?</p>

<p>AI makes mistakes — anyone who has spent a night wrestling with a hallucinating model knows the struggle. But the deeper question isn’t about accuracy. It’s about how AI <em>values</em> truth.</p>

<p>From a young age, we learn to distrust liars and conmen. We quickly grasp that a fact doesn’t carry the same weight depending on its origin, context, and consistency. We learn to fact-check, cross-reference, and challenge. In short, we learn to judge the worth of information.</p>

<p>A Large Language Model has none of that. It has no understanding of the facts it processes — it doesn’t even perceive them as facts. It doesn’t evaluate truth the way humans do. It amplifies what is statistically reinforced. And that’s where repetition comes into play.</p>

<hr />

<h1 id="how-to-train-your-ai">How to train your AI</h1>

<p>Large Language Models (LLMs) — a subset of Artificial Intelligence — are most commonly encountered as chatbots. They generate text by predicting the most probable next word given a sequence, much like the autocomplete on your phone’s keyboard, but operating at a vastly larger scale.</p>

<p>They learn this by ingesting enormous <em>quantities</em> of data, almost entirely text, in the form of training datasets. These used to be carefully hand-crafted by research teams. Today, they are effectively the entire internet — assembled by giving the training pipeline near-unfiltered access to search engines and every website they can reach.</p>

<h2 id="how-artists-paved-the-way-for-russian-propaganda-to-spread">How artists paved the way for Russian propaganda to spread</h2>

<p>Flooding AI with the raw internet quickly created problems. Artists and creators began raising copyright claims, arguing that their work was being scraped and used for training without consent. At the same time, some websites started deliberately engineering their content to attract AI crawlers and get indexed by them.</p>

<p>Both pressures led to the emergence of a set of techniques now broadly called AI Search Engine Optimization (AI SEO). Two tools sit at the center of this: <code class="language-plaintext highlighter-rouge">robots.txt</code> — a decades-old standard that tells crawlers which pages to skip — and the more recent <code class="language-plaintext highlighter-rouge">agents.txt</code>, a proposed convention designed specifically to signal AI scrapers (still emerging and not yet universally adopted) <sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>. Paired with content strategies tuned to how LLMs rank and retrieve information, these files give anyone with a web server a lever to influence what AI learns <sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup> <sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">3</a></sup>.</p>

<p>Which raises an uncomfortable question: what exactly does an AI consider worth knowing?</p>

<hr />

<h1 id="what-matters-to-an-ai">What matters to an AI</h1>

<p>Web crawling and indexing have been researched and refined for decades. As a result, much of how AI scrapers evaluate pages borrows heavily from the playbook that <em>Google</em>, <em>Bing</em> or <em>Yandex</em> developed. The key difference: search engines maintain a live, continuously updated index, while an LLM trains on a static snapshot of the web taken at a point in time. Once training ends, that snapshot is frozen.</p>

<h2 id="web-structure">Web structure</h2>

<p>One of the most important signals — for both search engines and AI crawlers — is page structure. Proper use of semantic HTML tags (<code class="language-plaintext highlighter-rouge">&lt;h1&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;h2&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;p&gt;</code>) establishes an information hierarchy that crawlers can parse. Alternative text on images, canonical URLs, and schema markup all contribute to how content gets categorized and weighted.</p>

<p>Some systems even allow web owners to integrate SEO metadata directly into their pages. Here, for example, is the SEO block generated automatically for my latest post.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Begin Jekyll SEO tag v2.8.0 --&gt;</span>
<span class="nt">&lt;title&gt;</span>The web inside FiveM: From browser to full remote control | Pyth3rEx<span class="nt">&lt;/title&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"generator"</span> <span class="na">content=</span><span class="s">"Jekyll v4.4.1"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:title"</span> <span class="na">content=</span><span class="s">"The web inside FiveM: From browser to full remote control"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"author"</span> <span class="na">content=</span><span class="s">"Pyth3rEx"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:locale"</span> <span class="na">content=</span><span class="s">"en_US"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"description"</span> <span class="na">content=</span><span class="s">"A player typed something into a text field. Now an attacker is reading files on another player’s computer. Your server didn’t get hacked. You were never the target. But you are the one who let it happen."</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:description"</span> <span class="na">content=</span><span class="s">"A player typed something into a text field. Now an attacker is reading files on another player’s computer. Your server didn’t get hacked. You were never the target. But you are the one who let it happen."</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"canonical"</span> <span class="na">href=</span><span class="s">"https://pyth3rex.github.io/blog/2026/03/26/fivem-web-surface/"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:url"</span> <span class="na">content=</span><span class="s">"https://pyth3rex.github.io/blog/2026/03/26/fivem-web-surface/"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:site_name"</span> <span class="na">content=</span><span class="s">"Pyth3rEx"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:type"</span> <span class="na">content=</span><span class="s">"article"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"article:published_time"</span> <span class="na">content=</span><span class="s">"2026-03-26T00:00:00+00:00"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"twitter:card"</span> <span class="na">content=</span><span class="s">"summary"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"twitter:title"</span> <span class="na">content=</span><span class="s">"The web inside FiveM: From browser to full remote control"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;script </span><span class="na">type=</span><span class="s">"application/ld+json"</span><span class="nt">&gt;</span>
    <span class="p">{</span><span class="dl">"</span><span class="s2">@context</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">https://schema.org</span><span class="dl">"</span><span class="p">,</span><span class="dl">"</span><span class="s2">@type</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">BlogPosting</span><span class="dl">"</span><span class="p">,</span><span class="dl">"</span><span class="s2">author</span><span class="dl">"</span><span class="p">:{</span><span class="dl">"</span><span class="s2">@type</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">Person</span><span class="dl">"</span><span class="p">,</span><span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">Pyth3rEx</span><span class="dl">"</span><span class="p">},</span><span class="dl">"</span><span class="s2">dateModified</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">2026-03-26T00:00:00+00:00</span><span class="dl">"</span><span class="p">,</span><span class="dl">"</span><span class="s2">datePublished</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">2026-03-26T00:00:00+00:00</span><span class="dl">"</span><span class="p">,</span><span class="dl">"</span><span class="s2">description</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">A player typed something into a text field. Now an attacker is reading files on another player’s computer. Your server didn’t get hacked. You were never the target. But you are the one who let it happen.</span><span class="dl">"</span><span class="p">,</span><span class="dl">"</span><span class="s2">headline</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">The web inside FiveM: From browser to full remote control</span><span class="dl">"</span><span class="p">,</span><span class="dl">"</span><span class="s2">mainEntityOfPage</span><span class="dl">"</span><span class="p">:{</span><span class="dl">"</span><span class="s2">@type</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">WebPage</span><span class="dl">"</span><span class="p">,</span><span class="dl">"</span><span class="s2">@id</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">https://pyth3rex.github.io/blog/2026/03/26/fivem-web-surface/</span><span class="dl">"</span><span class="p">},</span><span class="dl">"</span><span class="s2">url</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">https://pyth3rex.github.io/blog/2026/03/26/fivem-web-surface/</span><span class="dl">"</span><span class="p">}</span><span class="nt">&lt;/script&gt;</span>
<span class="c">&lt;!-- End Jekyll SEO tag --&gt;</span>
</code></pre></div></div>

<h2 id="conversational-queries">Conversational Queries</h2>

<p>AI models are particularly receptive to conversational text — content that directly answers questions using the full 5Ws: who, what, where, when, and why. This matters most during the fine-tuning phase, where models are shaped to respond helpfully to user prompts. Text that maps naturally onto a question-and-answer format gets absorbed with minimal friction, feeding almost directly into how the model learns to respond. A site that phrases its content as answers to common questions isn’t just optimizing for search engines — it’s optimizing for AI.</p>

<h2 id="multimodal-searching">Multimodal searching</h2>

<p>Multimodal content — text paired with images, video, or audio covering the same subject — reinforces the same semantic content through multiple channels. A training pipeline will collect the text on a page alongside its associated media. These aren’t necessarily processed together, but each one registers as another data point pointing at the same concept, compounding its weight in the model’s understanding.</p>

<h2 id="trust--authority">Trust &amp; Authority</h2>

<p>E-E-A-T (Experience, Expertise, Authoritativeness, and Trustworthiness) is a framework from Google’s Search Quality Rater Guidelines <sup id="fnref:4"><a href="#fn:4" class="footnote" rel="footnote" role="doc-noteref">4</a></sup>, used by human evaluators to assess content quality and inform ranking decisions. It is not a direct algorithmic signal, but it shapes what gets rated as high-quality — and therefore what ends up heavily indexed. Similarly to multimodal reinforcement, the underlying mechanism is repetition and cross-referencing.</p>

<h3 id="experience-e">Experience (E)</h3>

<ul>
  <li>Is this source established in the domain?</li>
  <li>Is it a brand-new website?</li>
  <li>Fresh WHOIS records?</li>
  <li>No post history?</li>
</ul>

<h3 id="expertise-e">Expertise (E)</h3>

<ul>
  <li>Is the author qualified?</li>
  <li>Does the author display certifications, experience, or deep subject-matter knowledge?</li>
  <li>Does the author claim published works in the field?</li>
</ul>

<h3 id="authoritativeness-a">Authoritativeness (A)</h3>

<ul>
  <li>Is the author reputable amongst peers?</li>
  <li>Is the author’s work referenced across the field?</li>
  <li>Is the content cited or reused by other high-ranking E-E-A-T sources?</li>
</ul>

<h3 id="trustworthiness-t">Trustworthiness (T)</h3>

<ul>
  <li>Are claims backed by citations or primary sources?</li>
  <li>Is authorship transparent?</li>
  <li>Is the site served over HTTPS?</li>
  <li>Is the content kept up to date?</li>
</ul>

<hr />

<h1 id="the-truth-network">The Truth Network</h1>

<h2 id="what-is-the-pravda-network">What is the Pravda Network</h2>

<p>Someone read those rules — and got to work long before the rest of us were paying attention.</p>

<p>The Pravda Network is a coordinated array of websites and social media vectors producing and amplifying pro-Russian content, aimed at Ukraine and the countries considered friendly to it. On the surface, it looks like a wave of politically-charged news outlets that appeared around 2023, in the wake of Russia’s full-scale invasion:</p>

<ul>
  <li><em>pravda-fr[.]com</em> | France</li>
  <li><em>pravda-de[.]com</em> | Germany, Austria, Switzerland</li>
  <li><em>pravda-pl[.]com</em> | Poland</li>
  <li><em>pravda-es[.]com</em> | Spain</li>
  <li><em>pravda-en[.]com</em> | UK / USA</li>
</ul>

<p>All of these share striking similarities beyond the name:</p>

<ul>
  <li>Articles</li>
  <li>Graphical interfaces</li>
  <li>Code snippets</li>
  <li>Registration dates</li>
  <li>Infrastructure</li>
</ul>

<p><img src="/assets/pravda-bank-chatbot-russian-lies/pravda-portal-kombat-network-infrastructure.png" alt="&quot;Portal Kombat&quot; network infrastructure diagram" class="invert" /></p>
<blockquote>
  <p><em>“Portal Kombat” network infrastructure — shared server cluster (AS49352), SSL certificate, and favicon across all sites. Source: VIGINUM.<sup id="fnref:5"><a href="#fn:5" class="footnote" rel="footnote" role="doc-noteref">5</a></sup></em></p>
</blockquote>

<p>At the time of writing, the Pravda Network contains almost 8 million articles, growing steadily with bursts of activity timed to Russian military operations.</p>

<h2 id="a-decade-in-the-making">A decade in the making</h2>

<p>The 2023 date is accurate — but it is the wrong date to look at.</p>

<p>The infrastructure running the Pravda Network traces back to at least 2013. The same IP ranges, the same hosting cluster, the same operator fingerprints. A decade before the multilingual propaganda push, earlier generations of sites quietly occupied those same servers, publishing regional news and unremarkable filler. What they were building was age, backlinks, and domain authority — exactly the signals that E-E-A-T rewards. By the time the 2023 wave launched, the domains already looked established to every crawler that mattered. The network had spent ten years manufacturing the credibility it needed.</p>

<p>This is not an unusual pattern. It is a documented one — and not exclusive to Russia.</p>

<p>ByteDance operates two distinct versions of the same platform. Direct testing of both found that Douyin — the Chinese version — served predominantly educational content to an under-14 profile, while TikTok delivered entertainment and low-engagement content to the same profile. Douyin enforces a 40-minute daily screen cap for users under 14 by default; TikTok’s equivalent controls are optional and, in Europe, not available at all <sup id="fnref:6"><a href="#fn:6" class="footnote" rel="footnote" role="doc-noteref">6</a></sup>. The regulatory and algorithmic treatment of the two audiences is structurally different. Whether that gap is policy or emergent outcome is a question worth its own post.</p>

<p>The Pravda Network operated on the same structural logic, at the level of facts rather than attention. Slow, patient, infrastructural. The signals were in the registration records, the IP history, the cross-linking patterns. None of it was hidden. It simply never crossed the threshold that would have made it someone’s problem to act on.</p>

<h2 id="how-does-it-work">How does it work</h2>

<p>The network exploits every E-E-A-T signal covered above. But it goes further — the goal was never only to mislead human readers. It was to contaminate the training data that their AI assistants would learn from.</p>

<h3 id="typosquatting--social-media-flooding">Typosquatting &amp; social media flooding</h3>

<p>The Pravda Network does not only publish — it mimics. Several sites in the network use domain names that are visually close to legitimate, well-established outlets. <code class="language-plaintext highlighter-rouge">lepoint.wf</code> is barely distinguishable from <code class="language-plaintext highlighter-rouge">lepoint.fr</code> at a glance, especially when a URL is truncated in a mobile notification or a social media preview. A reader who shares the article has no reason to look twice at the domain. Neither does the crawler that indexes it.<sup id="fnref:5:1"><a href="#fn:5" class="footnote" rel="footnote" role="doc-noteref">5</a></sup></p>

<p><img src="/assets/pravda-bank-chatbot-russian-lies/pravda-portal-kombat-lepoint-typosquating.png" alt="&quot;Le Point&quot; typosquatted page by Pravda" /></p>
<blockquote>
  <p><em>Typosquatted “Le Point” article on lepoint.wf, mimicking the legitimate lepoint.fr domain. Source: VIGINUM.<sup id="fnref:5:2"><a href="#fn:5" class="footnote" rel="footnote" role="doc-noteref">5</a></sup></em></p>
</blockquote>

<p>The mechanism has a second effect that compounds the first. Every share on social media is an engagement signal — and from the perspective of both platform algorithms and search crawlers, engagement is indistinguishable from endorsement. A reader sharing an article to mock it sends the same upstream signal as one sharing it in agreement: this content attracted attention, show it to more people.</p>

<p>The removal of dislike and downvote mechanisms on major platforms has made this structural problem worse. TikTok and Instagram no longer surface negative engagement at the content level. A post amplifying a fabricated claim accrues views and shares with no countervailing signal. The recommendation engine reads the engagement curve and optimises for it. The crawler reads the backlinks and inbound traffic, and raises the domain’s authority score accordingly.</p>

<p>This closes a feedback loop that Pravda exploits at every step: a typosquatted domain publishes a fabricated claim, social media algorithms amplify it as engaging content, and every share becomes a citation in the eyes of the next training crawl.</p>

<h3 id="out-of-category-luring">Out-of-category luring</h3>

<p>The sites do not publish only political content. They publish sport scores, recipe articles, celebrity news, technology briefings, science summaries — most of it lifted wholesale from legitimate outlets and republished with minimal alteration. The propaganda payload accounts for a fraction of total volume. That fraction is the point.</p>

<p><img src="/assets/pravda-bank-chatbot-russian-lies/pravda_portal_kombat_network_categories.png" alt="&quot;Portal Kombat&quot; network category distribution diagram" class="invert" /></p>
<blockquote>
  <p><em>“Portal Kombat” network posts as percentage distribution across categories. Source: portal-kombat.com.<sup id="fnref:7"><a href="#fn:7" class="footnote" rel="footnote" role="doc-noteref">7</a></sup></em></p>
</blockquote>

<p>From a training dataset perspective, a site that is 80% sport, science, and lifestyle content does not look like a propaganda vector — it looks like a general-interest news aggregator, exactly the kind of source a pipeline should include. Automated filters designed to exclude ideologically uniform or politically biased domains will pass it cleanly. The poisoned content rides inside.<sup id="fnref:8"><a href="#fn:8" class="footnote" rel="footnote" role="doc-noteref">8</a></sup></p>

<p>For the human reader, the effect is different but complementary. Someone who landed on the site for a football match report is already inside. The recommended articles sidebar, the related stories widget, the category navigation — all of it surfaces political content in a context where critical filters are lower. They arrived for sport. They leave having scrolled past four articles on NATO expansion.</p>

<p>Neither the crawler nor the reader was targeted by the content they came for. Both were targeted by what surrounded it.</p>

<h3 id="ai-poisoning">AI Poisoning</h3>

<p>The earlier techniques feed the human. This one feeds the model directly.</p>

<p>Most major LLM training pipelines consume Common Crawl — a publicly available archive that continuously snapshots the web. Whatever lands in Common Crawl has a chance of landing in a model’s weights. From an adversarial standpoint, it is a write interface to every AI trained on it.</p>

<p>As of November 2025, the DFRLab documented approximately 40,000 English-language Pravda articles archived in Common Crawl <sup id="fnref:9"><a href="#fn:9" class="footnote" rel="footnote" role="doc-noteref">9</a></sup>. In November 2024, the count was 37. A thousand-fold increase in twelve months <sup id="fnref:9:1"><a href="#fn:9" class="footnote" rel="footnote" role="doc-noteref">9</a></sup>.</p>

<p>To verify the effect, DFRLab researchers tested Llama 3.1 405B Base — Meta’s unfiltered base model, no safety tuning applied — using a text-completion approach: seed the model with an opening sentence from a known Pravda article, observe what it generates. The model reproduced several narratives nearly verbatim: an RT article advancing a Kremlin falsehood on U.S.-Ukrainian biological laboratories, and a CGTN documentary on the 20th anniversary of the Iraq War — published to coincide with Biden’s 2023 Summit for Democracy <sup id="fnref:9:2"><a href="#fn:9" class="footnote" rel="footnote" role="doc-noteref">9</a></sup>. Absorbed as fact, indistinguishable to the model from anything else it had learned.</p>

<p>The question this raises is how much poisoned content is actually needed. Souly et al. studied <em>“pretraining poisoning assuming adversaries control a percentage of the training corpus”</em> and found the answer unsettling <sup id="fnref:10"><a href="#fn:10" class="footnote" rel="footnote" role="doc-noteref">10</a></sup>. <em>“This work demonstrates for the first time that poisoning attacks instead require a near-constant number of documents regardless of dataset size”</em> — approximately 250, consistent across all tested model and dataset sizes. Correcting it requires a full retraining cycle <sup id="fnref:10:1"><a href="#fn:10" class="footnote" rel="footnote" role="doc-noteref">10</a></sup>.</p>

<p>Pravda published millions of articles. The bar was orders of magnitude lower.</p>

<p>The practical consequence is this: a model trained on a contaminated snapshot does not need to be further tampered with. No API attack. No prompt injection. No jailbreak. The disinformation is already inside — baked into the weights during pretraining, expressed with the same confidence as any other learned fact, invisible to both the model and the user asking the question.</p>

<h2 id="why-should-you-care">Why should you care?</h2>

<p>Because the scenario in the opening paragraph is not hypothetical.</p>

<p>The models are already trained. The contaminated snapshots are already frozen into weights. The bank’s chatbot, the travel assistant, the customer service bot your insurance company just deployed — they were built on training pipelines that consumed Common Crawl, and Common Crawl contains Pravda content. Not as a risk. As a reality.<sup id="fnref:12"><a href="#fn:12" class="footnote" rel="footnote" role="doc-noteref">11</a></sup></p>

<p>The user has no way to detect this. A model that reproduces a Kremlin narrative does not flag it as such. It responds with the same confidence as when it states a capital city or a compound interest formula. The source is invisible. The contamination is indistinguishable from everything else the model learned.</p>

<p>The scale makes this harder to dismiss. Pravda published millions of articles across a coordinated network designed from the ground up to pass every quality filter a training pipeline applies. It built domain authority over a decade. It diversified content to avoid clustering. It timed publishing bursts to coincide with news cycles, when crawlers are most active. It did not need to compromise a single AI company or intercept a single training run. It simply published — and waited for the internet to do the rest: the perfect supply-chain attack.</p>

<p>The fix is not straightforward. Correcting poisoned weights requires a full retraining cycle on a clean dataset — assuming the contaminated sources can be identified and removed in the first place. There is no patch. There is no rollback. For models already deployed, the disinformation is baked in.<sup id="fnref:11"><a href="#fn:11" class="footnote" rel="footnote" role="doc-noteref">12</a></sup></p>

<p>This is not a problem that belongs to any one company or any one model. It is a structural vulnerability of training on the open web at scale. Every model trained on a Common Crawl snapshot from the last two years carries some probability of having absorbed Pravda content — and has no mechanism to tell you when it is expressing it.<sup id="fnref:13"><a href="#fn:13" class="footnote" rel="footnote" role="doc-noteref">13</a></sup></p>

<p>The right response is not to stop using AI. It is to understand what AI is: a system that amplifies statistical patterns, not one that evaluates truth. On factual claims about geopolitics, ongoing conflicts, or anything where a motivated actor has had years and millions of articles to shape the training distribution — verify independently.</p>

<p>The Pravda Network did not need to hack your bank. It just needed your bank to trust the internet. And as we have seen with recent history, Pravda was just the first one to do it at scale.</p>

<h1 id="references">References</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>Osele, D. (2025). <em>agents.txt: Standard for AI agent discovery</em> [Software repository]. GitHub. <a href="https://github.com/dennj/agents.txt">https://github.com/dennj/agents.txt</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2">
      <p>Danet, D. (2025). <em>LLM grooming: A new cognitive threat to generative AI</em>. HAL Open Science. <a href="https://hal.science/hal-05241525">https://hal.science/hal-05241525</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3">
      <p>Schulte, J., Bleeker, M., &amp; Kaufmann, P. (2026). <em>Don’t measure once: Measuring visibility in AI search (GEO)</em>. arXiv:2604.07585. <a href="https://arxiv.org/abs/2604.07585">https://arxiv.org/abs/2604.07585</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4">
      <p>Google LLC. (2025, September 11). <em>Search Quality Rater Guidelines</em>. <a href="https://guidelines.raterhub.com/searchqualityevaluatorguidelines.pdf">https://guidelines.raterhub.com/searchqualityevaluatorguidelines.pdf</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5">
      <p>VIGINUM. (2024, February 12). <em>Portal Kombat: A structured and coordinated pro-Russian propaganda network</em> (Technical Report, Part 1). Secrétariat général de la défense et de la sécurité nationale (SGDSN). <a href="https://www.sgdsn.gouv.fr/files/files/20240212_NP_SGDSN_VIGINUM_PORTAL-KOMBAT-NETWORK_ENG_VF.pdf">https://www.sgdsn.gouv.fr/files/files/20240212_NP_SGDSN_VIGINUM_PORTAL-KOMBAT-NETWORK_ENG_VF.pdf</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:5:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:5:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:6">
      <p>Schumann, N. (2025, November 27). <em>Fact check: Is China using TikTok to ‘dumb down’ European children?</em> Euronews. <a href="https://www.euronews.com/my-europe/2025/11/27/fact-check-is-china-using-tiktok-to-dumb-down-european-children">https://www.euronews.com/my-europe/2025/11/27/fact-check-is-china-using-tiktok-to-dumb-down-european-children</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:7">
      <p>Portal Kombat. (n.d.). <em>Pravda in numbers: Content and network analysis</em> [Interactive dashboard]. <a href="https://portal-kombat.com/">https://portal-kombat.com/</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:8">
      <p>VIGINUM. (2024, February 14). <em>Portal Kombat: A structured and coordinated pro-Russian propaganda network</em> (Technical Report, Part 2). Secrétariat général de la défense et de la sécurité nationale (SGDSN). <a href="https://www.sgdsn.gouv.fr/files/files/Publications/20240214_NP_SGDSN_VIGINUM_PORTAL-KOMBAT-NETWORK_PART2_ENG_VF.pdf">https://www.sgdsn.gouv.fr/files/files/Publications/20240214_NP_SGDSN_VIGINUM_PORTAL-KOMBAT-NETWORK_PART2_ENG_VF.pdf</a> <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:9">
      <p>Digital Forensic Research Lab. (2026, April 8). <em>Pravda in the pipeline: Early evidence of state-adjacent propaganda in AI training data</em>. Atlantic Council. <a href="https://dfrlab.org/2026/04/08/pravda-in-the-pipeline/">https://dfrlab.org/2026/04/08/pravda-in-the-pipeline/</a> <a href="#fnref:9" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:9:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:9:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:10">
      <p>Souly, A., Rando, J., Chapman, E., Davies, X., Hasircioglu, B., Shereen, E., Mougan, C., Mavroudis, V., Jones, E., Hicks, C., Carlini, N., Gal, Y., &amp; Kirk, R. (2025). <em>Poisoning attacks on LLMs require a near-constant number of poison samples</em>. arXiv:2510.07192. <a href="https://arxiv.org/abs/2510.07192">https://arxiv.org/abs/2510.07192</a> <a href="#fnref:10" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:10:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:12">
      <p>Cyber Jack. (2025, March 11). <em>Russia’s ‘Pravda’ disinformation network is poisoning Western AI models</em>. Enterprise Security Tech. <a href="https://www.enterprisesecuritytech.com/post/russia-s-pravda-disinformation-network-is-poisoning-western-ai-models">https://www.enterprisesecuritytech.com/post/russia-s-pravda-disinformation-network-is-poisoning-western-ai-models</a> <a href="#fnref:12" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:11">
      <p>Freuden, S., &amp; Miguel Serrano, R. (2025, April 10). <em>LLM grooming: a new strategy to weaponise AI for FIMI purposes</em> [Webinar]. EU DisinfoLab. <a href="https://www.disinfo.eu/outreach/our-webinars/10-april-llm-grooming-a-new-strategy-to-weaponise-ai-for-fimi-purposes/">https://www.disinfo.eu/outreach/our-webinars/10-april-llm-grooming-a-new-strategy-to-weaponise-ai-for-fimi-purposes/</a> <a href="#fnref:11" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:13">
      <p>EDMO Task Force on 2024 European Elections. (2024, April 16). <em>Disinfo Bulletin – Issue 7: Russian disinformation operation “Portal Kombat” is expanding in the EU</em>. European Digital Media Observatory (EDMO). <a href="https://ec.europa.eu/newsroom/edmo/newsletter-archives/52424">https://ec.europa.eu/newsroom/edmo/newsletter-archives/52424</a> <a href="#fnref:13" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Pyth3rEx</name></author><category term="disinformation" /><category term="propaganda" /><category term="misinformation" /><category term="fake-news" /><category term="russia" /><category term="russian-propaganda" /><category term="information-warfare" /><category term="geopolitics" /><category term="llm" /><category term="large-language-models" /><category term="ai-security" /><category term="data-poisoning" /><category term="training-data" /><category term="common-crawl" /><category term="ai-seo" /><category term="chatbot" /><category term="portal-kombat" /><category term="pravda-network" /><category term="typosquatting" /><category term="social-engineering" /><category term="cybersecurity" /><category term="security-awareness" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">The web inside FiveM: From browser to full remote control</title><link href="https://pyth3rex.github.io/blog/2026/03/26/fivem-web-surface/" rel="alternate" type="text/html" title="The web inside FiveM: From browser to full remote control" /><published>2026-03-26T00:00:00+00:00</published><updated>2026-03-26T00:00:00+00:00</updated><id>https://pyth3rex.github.io/blog/2026/03/26/fivem-web-surface</id><content type="html" xml:base="https://pyth3rex.github.io/blog/2026/03/26/fivem-web-surface/"><![CDATA[<p>A player typed something into a text field. Now an attacker is reading files on another player’s computer.
Your server didn’t get hacked. You were never the target. But you are the one who let it happen.</p>

<hr />

<h1 id="part-1-recap">Part 1 recap</h1>

<p>Part 1 was about the server. An attacker connected, fired events the server wasn’t expecting, and
walked away with whatever the scripts handed over. The attacker was still a player — present on the
server, operating within the network stack.</p>

<p>That stays true here. The attacker is still a connected player. What changes is the target.
In Part 1 the server was the victim. In this post, so are the other players.</p>

<p>FiveM ships a Chromium browser inside every game client. Developers use it to build custom UIs — inventory menus, HUD
overlays, admin panels. Those UIs render data. Some of that data was written by other players. If it
is rendered without sanitisation, it executes.</p>

<p>A payload stored in a player-controlled field — a name, an item description, a support ticket — sits
in the database and waits. Every client that opens the affected UI runs it. The attacker can be
offline. The payload keeps firing.</p>

<p>That is the first shift. The second is where it leads. The Chromium instance running NUI is not
isolated from the host machine. It has a designed bridge to the game engine, and through that bridge,
to game functions and eventually to the operating system. A payload that starts as an <code class="language-plaintext highlighter-rouge">innerHTML</code>
injection can end on the victim’s filesystem — outside the game, outside the server, on a real
machine.</p>

<hr />

<h1 id="the-nui--web-layer">The NUI / Web Layer</h1>

<p>FiveM’s NUI system is a Chromium browser running inside the game client. Developers build custom UIs
with HTML, CSS, and JavaScript — inventory menus, HUDs, admin panels, ticket queues — exactly the
same way you build a website. The stack is standard. The attack surface is standard. If you have
done web security before, you already know the attack. If you haven’t, a quick look at any bug
bounty leaderboard will show you how common this class of vulnerability is and how little it takes
to exploit it. The question is what the context makes possible.</p>

<h1 id="web-security-in-a-game-is-worse">Web security in a game is worse</h1>

<p>In a normal browser, XSS<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> is bounded. The sandbox limits system access. Same-origin policy restricts
what an injected JavaScript can reach. Exfiltrating a session cookie is the ceiling for most web XSS, and you’ll rarely if ever end up
with a fully compromised system from a web-vectored attack alone<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>.</p>

<p>NUI does not have that ceiling. The Chromium instance runs inside a process that has direct, designed
access to the game engine. There is no boundary between the web layer and the game layer — that
boundary was intentionally removed so that JavaScript can communicate with Lua and Lua can communicate
with JavaScript. The same design choice that makes custom UIs possible is what makes XSS here
categorically worse than XSS in a web app.</p>

<h1 id="the-bridge">The bridge</h1>

<p>The communication mechanism is worth understanding before the escalation. Two functions carry traffic
across the boundary:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">SendNUIMessage</code> — Lua to JavaScript. Sends a JSON object into the browser, received by a
<code class="language-plaintext highlighter-rouge">window.addEventListener('message', ...)</code> handler in JS.</li>
  <li><code class="language-plaintext highlighter-rouge">RegisterNUICallback</code> — JavaScript to Lua. JS makes an HTTP POST to
<code class="language-plaintext highlighter-rouge">https://${GetParentResourceName()}/callbackName</code>; the registered Lua handler receives the body.</li>
</ul>

<p>Data flows in both directions. A payload that lands in the browser can use <code class="language-plaintext highlighter-rouge">RegisterNUICallback</code> to
send data back to Lua — and client-side Lua has access to game state, player data, and game
functions. The bridge is the mechanism. Everything below is what happens when untrusted input reaches
the DOM on the wrong side of it.</p>

<blockquote>
  <p><strong>Note:</strong> Also notice the similarity with the vulnerabilities mentioned in Part 1? They also apply here. If you missed it, read about it <a href="/blog/2026/03/24/fivem-server-events/">here</a>.</p>
</blockquote>

<h1 id="case-study">Case study</h1>

<h2 id="step-1-noticing">Step 1: Noticing</h2>

<p>We join a new server — it’s got open police slots, great. Playing around we notice evidence bags:
placeable items like bullet casings that accept metadata comments, letting detectives add context
to evidence later in an investigation. Let’s dig.</p>

<h2 id="step-2-digging">Step 2: Digging</h2>

<p>The script is paid — no public source, documentation behind a paywall. A bit of OSINT surfaces an
outdated leak: obfuscated code and a three-year-old user guide. The UI has changed and the feature
list is half of what the script offers today, but that doesn’t matter. Core functions are rarely
rewritten from scratch. There’s a good chance the internals are similar, if not identical.</p>

<p>Digging through the docs, we find an example structure for item declarations using <code class="language-plaintext highlighter-rouge">filled_evidence_bag</code>.
Let’s check if that item exists on the server.</p>

<p>Back in FiveM we manipulate our inventory requests to request the <code class="language-plaintext highlighter-rouge">itemthatdoesnotexist</code> item.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SYSTEM: Item does not exist...
</code></pre></div></div>

<p>Alright, let’s try <code class="language-plaintext highlighter-rouge">filled_evidence_bag</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SYSTEM: User inventory already defined <span class="k">in </span>database
</code></pre></div></div>

<p>Jackpot. The item exists in the resource files. All we need now is an item that accepts metadata.</p>

<h2 id="step-3-proof-of-concept-poc">Step 3: Proof of Concept (PoC)</h2>

<p>All items have a metadata attribute — it’s just unused unless a script needs it. That means we can
assign metadata to the welcome guide handed out on character creation.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
    <span class="nf">alert</span><span class="p">(</span><span class="dl">'</span><span class="s1">Vulnerable</span><span class="dl">'</span><span class="p">)</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Setting this as the item’s metadata lets us test locally — no other player is affected. On inventory
open, a <code class="language-plaintext highlighter-rouge">Vulnerable</code> popup appears in the UI layer. The service is vulnerable.</p>

<h3 id="blind-xss">Blind XSS</h3>

<p>The inventory is not the only injection point. Admin reports are NUI too — player reports, ban
requests, ticket queues. A payload stored in a report body won’t visibly render as a script; the
staff member opens what looks like a normal ticket. The payload fires in their client, under their
permissions. They never see it execute. This is blind XSS: the attacker fires and goes offline.
The payload does the rest, sometimes years later.</p>

<h2 id="step-4-persist-via-stored-xss">Step 4: Persist via Stored XSS</h2>

<p>Several vectors are already in reach — one is already in place in our testing: the item’s metadata.
What if I log in from a second machine, drop the infected welcome guide on the ground, and pick it
up with another character?</p>

<p><code class="language-plaintext highlighter-rouge">Vulnerable</code> popup on the receiving screen. The payload persists with object state. Going back to
the evidence bag: if we were to create an infected bag and store it in the police station, we could
specifically target police players. Or we could go wide — hiding the payload in the chat bar, item
names, vehicle descriptions, gang tags. Possibilities are endless. One stored injection, every
player who opens the affected UI, no further interaction required.</p>

<h2 id="step-5-weaponize">Step 5: Weaponize</h2>

<p>Now that we know we can hit anyone, anywhere, it’s time to decide what to hit them with. The
simplest starting point is DOM manipulation — rewriting what the victim sees inside their own UI.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// html/app.js</span>
<span class="kd">var</span> <span class="nx">descriptionEl</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">[data-component="evidence-description"]</span><span class="dl">'</span><span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">descriptionEl</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">descriptionEl</span><span class="p">.</span><span class="nx">innerText</span> <span class="o">=</span> <span class="nx">SPOOFED_DESCRIPTION</span><span class="p">;</span>
<span class="p">}</span>

<span class="nf">fetch</span><span class="p">(</span><span class="nx">CALLBACK_ENDPOINT</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">({</span>
    <span class="na">action</span><span class="p">:</span> <span class="dl">'</span><span class="s1">transferEvidence</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">target</span><span class="p">:</span> <span class="nx">ATTACKER_INVENTORY</span><span class="p">,</span>
    <span class="na">item</span><span class="p">:</span> <span class="nx">EVIDENCE_BAG_ID</span>
  <span class="p">})</span>
<span class="p">});</span>
</code></pre></div></div>

<blockquote>
  <p><strong>Note:</strong> Specific payload syntax is intentionally omitted. This is a documented class of
vulnerability — the goal is to make the attack surface legible, not to provide a tutorial.</p>
</blockquote>

<p>The first part rewrites the evidence description in the victim’s UI — they see whatever we want
them to see. The second fires a <code class="language-plaintext highlighter-rouge">POST</code> to the inventory callback, requesting a transfer of the
item to our inventory. The victim opened their evidence bag. We took what was inside.</p>

<h2 id="step-6-elevate">Step 6: Elevate</h2>

<p>Now that we have visibility into what players see, we can think about elevating — using our foothold
to perform more destructive actions. Our payload has full DOM interactivity. If it’s sophisticated
enough it can scan, detect, and pivot autonomously. Let’s see what’s in the DOM.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"notif-label"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"notif-title"</span><span class="nt">&gt;</span>Payment Received<span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"notif-subtitle"</span><span class="nt">&gt;</span>Unemployment check - $15<span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>Convenient: the moment I ran my recon payload was the same instant I received my 15-minute
in-game paycheck. That notification was loaded in my DOM<sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">3</a></sup>. Oddly curious.</p>

<p>After digging into how FiveM handles NUI, the picture becomes clear. FiveM isolates resources in
separate iframes — they shouldn’t be able to see each other’s DOM. But many servers run a
centralised notification or display system: a single third-party script that routes all UI
elements through one stack for a consistent look. When that’s in place, all resources share the
same DOM, and any callback registered there is reachable from our payload.</p>

<p>Scanning the DOM for registered callbacks, one stands out:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- server.lua</span>
<span class="n">RegisterNUICallback</span><span class="p">(</span><span class="s1">'UIsystem:generalCallbacks'</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">cb</span><span class="p">)</span>
  <span class="c1">-- routes callback to the originating resource by name</span>
<span class="k">end</span><span class="p">)</span>
</code></pre></div></div>

<p>A generic pass-through — routes any callback to its originating resource without validation. That
is a wide pivot surface. Every resource on the server with a registered callback is now reachable
from our injection point.</p>

<p>The opening we are looking for is <code class="language-plaintext highlighter-rouge">window.invokeNative</code><sup id="fnref:4"><a href="#fn:4" class="footnote" rel="footnote" role="doc-noteref">4</a></sup>, which exposes game engine functions
directly to JavaScript running in NUI.</p>

<p>From injected JavaScript running in a victim’s NUI: teleport the player, spawn or delete entities,
trigger animations, call commands that would normally require server-side authorisation. The attacker
is not sending a crafted server event anymore. They are calling game engine functions directly from
inside the victim’s client, without touching the server at all.</p>

<p>The anticheat has no visibility into this. It is not a game modification. It is JavaScript executing
inside a browser that the game provides.</p>

<h2 id="step-7-exfiltrate">Step 7: Exfiltrate</h2>

<blockquote>
  <p><strong>Note:</strong> This section is deliberately vague and leans toward speculation rather than demonstration,
for obvious reasons. Keep an open mind.</p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">window.invokeNative</code> is not the ceiling.</p>

<p>The CEF instance running NUI is a browser. Browsers have APIs: clipboard read and write, microphone
access, camera access. In a standard browser these prompt for permission. Inside the game client,
the permission surface is different — prompts may not appear, or may appear in a context where the
player dismisses them without understanding what they are approving.</p>

<p>Beyond that: the CEF remote debug interface runs on <code class="language-plaintext highlighter-rouge">localhost:13172</code> while the game is open.<sup id="fnref:5"><a href="#fn:5" class="footnote" rel="footnote" role="doc-noteref">5</a></sup>
Any process on the same machine can attach to it and inject code into the running browser context,
or inspect and modify anything currently loaded. This is not an attacker capability — it is a
developer tool. But it is exposed by default, and accessible to anything running locally.</p>

<blockquote>
  <p><strong>Note:</strong> The activation and blocking of debug services on FiveM is not well documented. Most will
argue that if you don’t enable the tools, they aren’t enabled — but some claim it is possible to
force or bypass their activation. The documentation is thin; the threat model shouldn’t assume the
default is safe.</p>
</blockquote>

<p>With a payload executing in the NUI context and a foothold on the debug interface, the attacker can
operate at OS level — and they no longer need to be connected to the server. Reading local files via
<code class="language-plaintext highlighter-rouge">fetch</code> against <code class="language-plaintext highlighter-rouge">file://</code> paths, exfiltrating stored credentials, or dropping content to disk are
all within reach. The attacker has moved from manipulating a game UI to running arbitrary code on
the victim’s operating system.</p>

<hr />

<h1 id="the-end-state">The end state</h1>

<p>A detective opened an evidence bag.</p>

<p>The metadata field rendered without sanitisation. Our payload executed in their NUI context. It
rewrote the bag’s description — they saw whatever we wanted them to see. It fired a callback and
transferred the bag to our inventory. It scanned the DOM, found the centralised notification system,
and mapped every registered callback on the server. It called <code class="language-plaintext highlighter-rouge">window.invokeNative</code> — directly,
without touching the server — and issued game engine commands under their identity. Then it reached
the debug interface and read files off their machine.</p>

<p>They were just doing their job. Opening evidence, like every shift.</p>

<p>The payload had been sitting in that bag for days. We were offline. It fires on every detective who
opens it. The server saw none of this — no unusual events, no suspicious connections, nothing to
flag. The only trace is a text field in a database, waiting for the next person to open the right
menu.</p>

<p>This started with a developer using <code class="language-plaintext highlighter-rouge">innerHTML</code> instead of <code class="language-plaintext highlighter-rouge">textContent</code>.</p>

<h1 id="fixing-the-nui-layer">Fixing the NUI layer</h1>

<p>The same three principles from Part 1 apply here. The bridge runs in both directions; the
obligation runs in both directions.</p>

<p><strong>The render side.</strong></p>

<p>The JavaScript that displays player-controlled data is the first gate. By default, it does nothing
unless the data passes. <code class="language-plaintext highlighter-rouge">textContent</code> is the default — it does not invoke the HTML parser, so
injected markup is inert. If HTML rendering is genuinely required, DOMPurify runs first. No
exceptions for “trusted” sources: if the data touched the database and a player wrote it, it is
untrusted.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// html/app.js</span>
<span class="kd">function</span> <span class="nf">renderDescription</span><span class="p">(</span><span class="nx">data</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">var</span> <span class="nx">descEl</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">[data-component="evidence-description"]</span><span class="dl">'</span><span class="p">);</span>
  <span class="k">if </span><span class="p">(</span><span class="kc">null</span> <span class="o">!==</span> <span class="nx">descEl</span><span class="p">)</span> <span class="p">{</span>
  
    <span class="c1">// Type check</span>
    <span class="k">if </span><span class="p">(</span><span class="dl">'</span><span class="s1">string</span><span class="dl">'</span> <span class="o">===</span> <span class="k">typeof</span> <span class="nx">data</span><span class="p">.</span><span class="nx">description</span><span class="p">)</span> <span class="p">{</span>
    
      <span class="c1">// Render — textContent, never innerHTML</span>
      <span class="nx">descEl</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">description</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">}</span>
  
  <span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Default to negative. The function does nothing unless the element exists and the data is a string.
No render, no side effect.</p>

<p><strong>The callback side.</strong></p>

<p>Data arriving from the NUI layer is untrusted. A payload executing in the browser can call any
registered callback with any body it constructs. The Lua handler is the second gate — same layered
pattern as Part 1.</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- client.lua</span>
<span class="n">RegisterNUICallback</span><span class="p">(</span><span class="s1">'inventory:transferEvidence'</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">cb</span><span class="p">)</span>

    <span class="c1">-- Type check</span>
    <span class="k">if</span> <span class="s2">"string"</span> <span class="o">==</span> <span class="nb">type</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">itemId</span><span class="p">)</span> <span class="ow">and</span> <span class="s2">"number"</span> <span class="o">==</span> <span class="nb">type</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">target</span><span class="p">)</span> <span class="k">then</span>
    
        <span class="c1">-- Sanity check</span>
        <span class="k">if</span> <span class="mi">64</span> <span class="o">&gt;</span> <span class="o">#</span><span class="n">data</span><span class="p">.</span><span class="n">itemId</span> <span class="ow">and</span> <span class="mi">1</span> <span class="o">&lt;=</span> <span class="n">data</span><span class="p">.</span><span class="n">target</span> <span class="k">then</span>
        
            <span class="c1">-- Context check</span>
            <span class="k">if</span> <span class="kc">true</span> <span class="o">==</span> <span class="n">isValidItem</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">itemId</span><span class="p">)</span> <span class="ow">and</span> <span class="kc">true</span> <span class="o">==</span> <span class="n">DoesEntityExist</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">target</span><span class="p">)</span> <span class="k">then</span>
            
                <span class="c1">-- Perform the action</span>
                <span class="n">TriggerServerEvent</span><span class="p">(</span><span class="s1">'inventory:transferEvidence'</span><span class="p">,</span> <span class="n">data</span><span class="p">.</span><span class="n">itemId</span><span class="p">,</span> <span class="n">data</span><span class="p">.</span><span class="n">target</span><span class="p">)</span>
                <span class="n">cb</span><span class="p">({</span> <span class="n">success</span> <span class="o">=</span> <span class="kc">true</span> <span class="p">})</span>
                <span class="k">return</span>
            <span class="k">end</span>
        <span class="k">end</span>
    <span class="k">end</span>

    <span class="n">cb</span><span class="p">({</span> <span class="n">success</span> <span class="o">=</span> <span class="kc">false</span> <span class="p">})</span>
    <span class="k">return</span>
<span class="k">end</span><span class="p">)</span>
</code></pre></div></div>

<p>Default to negative. Silent <code class="language-plaintext highlighter-rouge">cb({ success = false })</code> and <code class="language-plaintext highlighter-rouge">return</code> on any failed check. The action
— a server event — only fires when every layer passes.</p>

<p>Three things worth naming explicitly:</p>

<ul>
  <li>
    <p><strong>Default to negative.</strong> Both sides of the bridge do nothing unless all conditions pass. No
render, no callback, no server event. Silent return.</p>
  </li>
  <li>
    <p><strong>Layered validation.</strong> Not a single guard — a sequence: type, sanity, context, then action.
Each layer is independent. A failure at any point drops the request.</p>
  </li>
  <li>
    <p><strong>Minimal surface.</strong> Only register the callbacks you need. The generic pass-through from Step 6
— routing any callback to any resource without validation — is the opposite of this. Each
registered callback is a decision; treat it like one.</p>
  </li>
</ul>

<p>A Content Security Policy on NUI HTML files adds a fourth layer: restrict <code class="language-plaintext highlighter-rouge">script-src</code> to your
own bundle and block inline execution. It does not prevent injection, but it severs the eval chain.
A payload that cannot execute inline and cannot reach an external endpoint is significantly less
useful, even if it lands.</p>

<hr />

<h1 id="closing">Closing</h1>

<p>Part 1 was one attacker, one server, one payload at a time. This is worse.</p>

<p>A single stored injection fires on every player who opens the affected UI — indefinitely, without
the attacker being present, without the server seeing anything unusual. The surface is not just
the server anymore. It is every client, every machine, every set of credentials sitting in a
browser profile on the same computer running the game.</p>

<p>Most servers won’t notice. The tell is not an alert or a spike — it is a detective who lost their
evidence bag and assumed it was a script bug. It is a staff member whose admin panel behaved
strangely for a moment. It is a player who saw a notification they didn’t expect. Or it’s nothing
at all. None of those get filed as security incidents. They get filed as bugs, or they don’t get
filed at all.</p>

<p>The attacker doesn’t need to be skilled. They need to find one field that renders without
sanitisation and one developer who reached for <code class="language-plaintext highlighter-rouge">innerHTML</code> because it was easier. That field
exists on most servers. That developer made that choice on most resources. The gap between exposed
and defensible is <code class="language-plaintext highlighter-rouge">textContent</code>, a type check on a callback handler, and the decision to treat
the NUI layer as what it is: a browser running untrusted input.</p>

<p>Two posts in, we have covered the server and the clients. There is a third layer we haven’t
touched — the database. The same input that executes in a browser can execute in a query. Part 3
is about what happens when untrusted data reaches SQL.</p>

<p>If this reached someone running a server, send it to them. Their players’ machines are not part
of the game — until they are.</p>

<hr />

<h1 id="notes">Notes</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>Cross-Site Scripting. An attacker injects a script into a page viewed by another user. The
browser executes it as if it were part of the page. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2">
      <p>A decade or two ago, browsers were a genuine vector for full system compromise. Systems were
less hardened, browser sandboxes were weaker, and exploitation toolkits made it routine. Modern
browsers have closed most of those paths — which is exactly what makes NUI’s exposure notable:
it reopens them by design. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3">
      <p>FiveM isolates resources in separate iframes — theoretically preventing cross-resource
communication. In this example the resources share the same NUI via a centralised display
script. Worth noting: even without a shared DOM, that doesn’t prevent a payload from calling
another resource’s registered callbacks directly. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4">
      <p><code class="language-plaintext highlighter-rouge">window.invokeNative</code> is a CEF-exposed function specific to the FiveM client. It is
undocumented officially but widely reverse-engineered by the community. Its availability and
scope may vary across client versions. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5">
      <p>The CEF remote debugging port (<code class="language-plaintext highlighter-rouge">13172</code> by default) is a Chromium DevTools Protocol endpoint.
Any application on localhost can attach to it while the game is running. It is a standard
developer tool, not a vulnerability — but its exposure is worth understanding in a threat model. <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Pyth3rEx</name></author><category term="fivem" /><category term="fivem-security" /><category term="gta-roleplay" /><category term="cfx" /><category term="lua" /><category term="lua-security" /><category term="server-events" /><category term="event-validation" /><category term="game-server-security" /><category term="game-server-hardening" /><category term="security-awareness" /><category term="red-team" /><category term="penetration-testing" /><category term="input-validation" /><category term="client-server" /><category term="roleplay-server" /><summary type="html"><![CDATA[A player typed something into a text field. Now an attacker is reading files on another player’s computer. Your server didn’t get hacked. You were never the target. But you are the one who let it happen.]]></summary></entry><entry><title type="html">Your Server Events Are a Security Hole</title><link href="https://pyth3rex.github.io/blog/2026/03/24/fivem-server-events/" rel="alternate" type="text/html" title="Your Server Events Are a Security Hole" /><published>2026-03-24T00:00:00+00:00</published><updated>2026-03-24T00:00:00+00:00</updated><id>https://pyth3rex.github.io/blog/2026/03/24/fivem-server-events</id><content type="html" xml:base="https://pyth3rex.github.io/blog/2026/03/24/fivem-server-events/"><![CDATA[<p>The attacker is already on your server. They connected like any other player. Your anticheat
cleared them. Now they’re reading your client files — which they have a copy of, because that’s
how FiveM works — mapping the event names your server listens for, noting which ones take
arguments. They’re not looking for a zero-day. They’re looking for a handler that forgot to
validate its input.</p>

<p>Most servers have several.</p>

<hr />

<h1 id="the-attack-surface">The attack surface</h1>

<p>FiveM scripts split across two layers: <em>server-side</em> and <em>client-side</em>. Client code runs on the
player’s machine. The player owns that machine. They can read every file in your resource, modify
any function, call anything they want.</p>

<p>What is less often considered is what that implies for the server. The two layers communicate
through events — the client fires a named event, the server handles it. The problem is that
<strong>any connected client can fire any registered server event, by name, with any arguments they
choose</strong>. The server has no way to distinguish a call from your UI from a call typed into a
console. It only sees an event and its payload.</p>

<h1 id="case-study">Case study</h1>

<p>Consider this:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- server.lua</span>
<span class="n">RegisterNetEvent</span><span class="p">(</span><span class="s1">'bank:withdraw'</span><span class="p">)</span>
<span class="n">AddEventHandler</span><span class="p">(</span><span class="s1">'bank:withdraw'</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span>
    <span class="kd">local</span> <span class="n">src</span> <span class="o">=</span> <span class="n">source</span>
    <span class="n">removeMoney</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">amount</span><span class="p">)</span>
<span class="k">end</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- client.lua</span>
<span class="k">function</span> <span class="nf">OnWithdrawConfirmed</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span> <span class="c1">-- Called when player confirms a withdrawal</span>
    <span class="n">TriggerServerEvent</span><span class="p">(</span><span class="s1">'bank:withdraw'</span><span class="p">,</span> <span class="n">amount</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This pattern is common across free and paid scripts on the <em>CFx forums</em>, <em>GitHub</em>, and the <em>Tebex</em>
marketplace. The server receives <code class="language-plaintext highlighter-rouge">amount</code> from the client and acts on it. The caller’s identity
(<code class="language-plaintext highlighter-rouge">src</code>) is resolved server-side, so that part is fine. What isn’t fine is the assumption that
<code class="language-plaintext highlighter-rouge">amount</code> can be trusted. In a legitimate flow the client wraps the call in checks:</p>

<ul>
  <li>Is the user at a bank?</li>
  <li>Does the user have enough funds?</li>
  <li>Is the amount within a sensible range?</li>
</ul>

<p>None of those checks exist on the server. They live on the client, where the attacker already has
full control.</p>

<blockquote>
  <p><strong>Note:</strong> The examples below are intentionally vague. The goal is not a hacking tutorial — these
are well-known techniques in the offensive world, but the focus here is awareness, not execution.</p>
</blockquote>

<p>Calling <code class="language-plaintext highlighter-rouge">bank:withdraw(100)</code> outside the normal UI flow goes through without issue. Clean log
entry, money received, no flags raised — from the middle of the Sandy Shores desert. The
service<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> is vulnerable.</p>

<p>So let’s put on our hacker hoodies and start thinking red:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- server.lua</span>
<span class="n">RegisterNetEvent</span><span class="p">(</span><span class="s1">'bank:transfer'</span><span class="p">)</span>
<span class="n">AddEventHandler</span><span class="p">(</span><span class="s1">'bank:transfer'</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="n">usr1</span><span class="p">,</span> <span class="n">usr2</span><span class="p">,</span> <span class="n">amount</span><span class="p">)</span>
    <span class="n">removeMoney</span><span class="p">(</span><span class="n">usr1</span><span class="p">,</span> <span class="n">amount</span><span class="p">)</span>
    <span class="n">addMoney</span><span class="p">(</span><span class="n">usr2</span><span class="p">,</span> <span class="n">amount</span><span class="p">)</span>
<span class="k">end</span><span class="p">)</span>
</code></pre></div></div>

<p>Jackpot. <code class="language-plaintext highlighter-rouge">bank:transfer</code>, a few lines below, has the same problem — and this time the attacker
controls both ends. Run it in a loop across all online players and the money flows to a single
account. Every log entry looks like a legitimate transfer. Only a manual audit would catch it,
and only if the attacker stayed within plausible bounds<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>.</p>

<p>Server events <strong>can be called from anywhere, by anyone, with any arguments</strong>. What that means
in practice:</p>

<ul>
  <li>Your logs are clean</li>
  <li>Your paid anticheat didn’t trigger</li>
  <li>Your staff has no idea</li>
</ul>

<p>This is full compromise. In a red team engagement this is a failed audit.</p>

<h1 id="remediation">Remediation</h1>

<p><strong>👏 DON’T 👏 TRUST 👏 THE 👏 USER 👏</strong></p>

<h2 id="scope-your-client-code">Scope your client code</h2>

<p>Client code handles the client: UI, visual effects, local state. Moving money, writing to a
database, spawning a vehicle server-side — that belongs in server code. If your client is doing
more than presenting state and sending requests, something is wrong.<sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">3</a></sup></p>

<h2 id="treat-every-request-as-hostile">Treat every request as hostile</h2>

<p>The client sends a request. The server validates it, decides, and either acts or rejects. That
order is non-negotiable. The client cannot be trusted to have already checked — it doesn’t matter
whether the client <em>would</em> check in the normal flow. Assume every incoming event is adversarial.</p>

<h2 id="validate-arguments">Validate arguments</h2>

<p>Check type, range, and plausibility on the server before touching anything. Negative withdrawal
amount? String where a number is expected? Reject immediately, no side effects.</p>

<h2 id="log-smart">Log smart</h2>

<p>Logging every transaction and reviewing it after the fact is not security — it is archaeology.
Flag anomalies in real time. A player firing more than five transactions per minute? Flag it. An
account receiving transfers from twenty different players in thirty seconds? Flag it.</p>

<blockquote>
  <p><strong>Note:</strong> With the growth of AI and consumer-level data infrastructure, there’s an interesting
open question here: training models on cross-server log data to surface anomalies the way tax
authorities hunt money laundering patterns.</p>
</blockquote>

<hr />

<h1 id="security-as-a-headspace-saah">Security as a headspace (SaaH)</h1>

<p>The fixes are not complex. Here is the original <code class="language-plaintext highlighter-rouge">bank:withdraw</code> handler with the four principles
applied — the diff in code is small; the diff in exposure is everything.</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- server.lua</span>
<span class="n">RegisterNetEvent</span><span class="p">(</span><span class="s1">'bank:withdraw'</span><span class="p">)</span>
<span class="n">AddEventHandler</span><span class="p">(</span><span class="s1">'bank:withdraw'</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span>
    <span class="kd">local</span> <span class="n">src</span> <span class="o">=</span> <span class="n">source</span>

    <span class="c1">-- Type, sanity &amp; context check</span>
    <span class="k">if</span> <span class="s2">"number"</span> <span class="o">==</span> <span class="nb">type</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span> <span class="ow">and</span> <span class="mi">0</span> <span class="o">&lt;</span> <span class="n">amount</span> <span class="ow">and</span> <span class="kc">true</span> <span class="o">==</span> <span class="n">isPlayerAtBank</span><span class="p">(</span><span class="n">src</span><span class="p">)</span> <span class="k">then</span>

      <span class="c1">-- Authorization</span>
      <span class="kd">local</span> <span class="n">balance</span> <span class="o">=</span> <span class="n">getPlayerBalance</span><span class="p">(</span><span class="n">src</span><span class="p">)</span>
      <span class="k">if</span> <span class="kc">nil</span> <span class="o">~=</span> <span class="n">balance</span> <span class="ow">and</span> <span class="n">balance</span> <span class="o">&gt;=</span> <span class="n">amount</span> <span class="k">then</span>

        <span class="c1">-- Perform the action</span>
        <span class="n">removeMoney</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">amount</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>

    <span class="k">return</span>
<span class="k">end</span><span class="p">)</span>
</code></pre></div></div>

<p>The same logic on <code class="language-plaintext highlighter-rouge">bank:transfer</code> — two players, two ends of the transaction, both need
validating. Neither can be trusted.</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- server.lua</span>
<span class="n">RegisterNetEvent</span><span class="p">(</span><span class="s1">'bank:transfer'</span><span class="p">)</span>
<span class="n">AddEventHandler</span><span class="p">(</span><span class="s1">'bank:transfer'</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="n">target</span><span class="p">,</span> <span class="n">amount</span><span class="p">)</span>
    <span class="kd">local</span> <span class="n">src</span> <span class="o">=</span> <span class="n">source</span>

    <span class="c1">-- Type, sanity &amp; target check</span>
    <span class="k">if</span> <span class="s2">"number"</span> <span class="o">==</span> <span class="nb">type</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span> <span class="ow">and</span> <span class="mi">0</span> <span class="o">&lt;</span> <span class="n">amount</span> <span class="ow">and</span> <span class="kc">true</span> <span class="o">==</span> <span class="n">DoesPlayerExist</span><span class="p">(</span><span class="n">target</span><span class="p">)</span> <span class="ow">and</span> <span class="n">src</span> <span class="o">~=</span> <span class="n">target</span> <span class="k">then</span>

      <span class="c1">-- Authorization</span>
      <span class="kd">local</span> <span class="n">balance</span> <span class="o">=</span> <span class="n">getPlayerBalance</span><span class="p">(</span><span class="n">src</span><span class="p">)</span>
      <span class="k">if</span> <span class="kc">nil</span> <span class="o">~=</span> <span class="n">balance</span> <span class="ow">and</span> <span class="n">balance</span> <span class="o">&gt;=</span> <span class="n">amount</span> <span class="k">then</span>

        <span class="c1">-- Perform the action</span>
        <span class="n">removeMoney</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">amount</span><span class="p">)</span>
        <span class="n">addMoney</span><span class="p">(</span><span class="n">target</span><span class="p">,</span> <span class="n">amount</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>

    <span class="k">return</span>
<span class="k">end</span><span class="p">)</span>
</code></pre></div></div>

<p>Notice <code class="language-plaintext highlighter-rouge">usr1</code> is gone — the server already knows the sender via <code class="language-plaintext highlighter-rouge">source</code>. Accepting it as a
client argument is exactly the kind of thing that gets abused.</p>

<p>Three things worth naming explicitly:</p>

<ul>
  <li>
    <p><strong>Default to negative.</strong> The function does nothing unless every condition passes. No action, no
side effect, silent <code class="language-plaintext highlighter-rouge">return</code>. That default path is also the right place to emit a log flag — it
should never fire in normal operation.<sup id="fnref:4"><a href="#fn:4" class="footnote" rel="footnote" role="doc-noteref">4</a></sup></p>
  </li>
  <li>
    <p><strong>Yoda conditions.</strong> Constant on the left: <code class="language-plaintext highlighter-rouge">"number" == type(amount)</code>. In Lua this is purely
stylistic — the constant is the reference point, the input is what gets tested against it.<sup id="fnref:5"><a href="#fn:5" class="footnote" rel="footnote" role="doc-noteref">5</a></sup></p>
  </li>
  <li>
    <p><strong>Layered validation.</strong> Not a single gate — a sequence of independent filters: type, range,
context, authorization. Every single check needs to pass to get to the next layer while a single negative will drop the request straight to the bin.</p>
  </li>
</ul>

<hr />

<h1 id="closing">Closing</h1>

<p>Low attacker bar, soft targets, simple fixes.</p>

<p>The threat model is not exotic. No zero-day, no sophisticated toolchain. The attacker is usually
someone with a Lua console and a list of event names copied from your client files — files they
have because your client runs on their machine. The entry point is the trust you handed out
without meaning to.</p>

<p>Most servers haven’t been visibly hit not because they’re secure but because they were either
lucky, hit quietly, or hit and never noticed. An economy drain that stays within plausible daily
variance leaves no obvious trace. Logs nobody reads might as well not exist. Your anticheat
catches aimbots and movement cheats. It has no visibility into a crafted event payload.</p>

<p>The gap between exposed and defensible is a few lines of Lua and the decision to treat the server
as what it is: a networked application that accepts untrusted input.</p>

<p>Server events are the most direct entry point — one handler, one attacker, one payload at a time.
Part 2 is a different shape of problems.</p>

<hr />

<h1 id="notes">Notes</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>In networking and security, “<em>service</em>” is a broad term. FiveM scripts can reasonably be
described as a service running inside a browser (Chromium/NUI), running on a network stack
(FiveM), running on a game engine (RAGE). <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2">
      <p>A common pattern is routing stolen funds through a temporary account then converting them
to in-game objects to break the audit trail. In some cases this has been used to frame other
players — the logs showed them receiving large transfers, leading staff to believe they were
responsible. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3">
      <p>This also applies to performance. Offloading work from the client reduces client-side
overhead and is the correct architectural pattern regardless of security posture. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4">
      <p>Anomaly detection at the event level is underused in FiveM. A handler that rejects more
than it accepts is a signal worth surfacing — especially on high-value events like transfers
or inventory mutations. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5">
      <p>In languages where <code class="language-plaintext highlighter-rouge">=</code> is assignment and <code class="language-plaintext highlighter-rouge">==</code> is comparison — C, JavaScript, PHP — writing
the constant on the left turns an accidental <code class="language-plaintext highlighter-rouge">=</code> into a compile-time or runtime error rather
than a silent bug. The pattern originates there; in Lua it is purely a readability convention. <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Pyth3rEx</name></author><category term="fivem" /><category term="fivem-security" /><category term="gta-roleplay" /><category term="cfx" /><category term="lua" /><category term="lua-security" /><category term="server-events" /><category term="event-validation" /><category term="game-server-security" /><category term="game-server-hardening" /><category term="security-awareness" /><category term="red-team" /><category term="penetration-testing" /><category term="input-validation" /><category term="client-server" /><category term="roleplay-server" /><summary type="html"><![CDATA[The attacker is already on your server. They connected like any other player. Your anticheat cleared them. Now they’re reading your client files — which they have a copy of, because that’s how FiveM works — mapping the event names your server listens for, noting which ones take arguments. They’re not looking for a zero-day. They’re looking for a handler that forgot to validate its input.]]></summary></entry></feed>