A support chatbot reads a ticket a customer submitted. Somewhere inside it, between the description of an ordinary problem, sit a few sentences not addressed to any human. They are addressed to the model. If they work, the chatbot says or does something nobody wrote it to do.

The attack is well known and most teams have put a filter in front of it. What gets less attention is that those filters are usually tuned on English, and a language like Persian lets the same instruction reach the model intact while the filter sees something else entirely.

Prompt injection in two paragraphs

A language model receives the system instructions, the user message, retrieved documents and tool output as one stream of text. It has no reliable way to tell which part is an instruction and which part merely looks like one. Anyone who can put text in front of the model can try to steer it.

So "the user" is not the only person to worry about. The author of any web page the application reads, any uploaded PDF, any ticket and any email can introduce text. That second case is called indirect injection, and in practice it is the more dangerous one, because the victim does nothing suspicious at all. We covered the general method for testing these applications in how to security-test an LLM application; this piece is about one layer of it: the text itself.

Why Persian changes the picture

Filters either search for strings or classify the input with a smaller model. Both assume the text arrives in roughly the shape it had when the filter was written. Persian breaks that assumption several ways, and none of them changes what the sentence means to the model.

One letter, two codepoints. Persian "ی" and Arabic "ي" are different characters that look alike in most fonts. The same is true of "ک" and "ك". A denylist written with one form does not see the other.

Spaces you cannot see. The zero-width non-joiner is a real character in Persian: remove it and the text becomes hard to read, so no application can simply strip it. Alongside it sit other zero-width characters that are not visible at all. A word with one of them in the middle is the same word to a reader and a different word to a string match.

Three sets of digits. Persian, Arabic-Indic and Latin digits all appear in Persian text. A rule written for one set misses the others.

Finglish. Writing Persian in Latin letters is common in Iran, and large models read it without difficulty. To a filter holding a list of Persian words, that text is not Persian at all.

Directional characters. Unicode has characters that change the direction text is displayed in. With them, what a support agent reads in the admin panel and what the model receives need not be the same string. This is the family that became known as Trojan Source in source code, and it works just as well in prose.

The shapes worth testing

The table describes shapes, not ready-made payloads. The point is that a team can build its own set.

Shape What it does to the text What it gets past
Homoglyph substitution Swaps "ی" and "ک" for their Arabic twins Word lists, string rules
Zero-width insertion Breaks a word from the inside without changing how it looks Exact string matching, some classifiers
Digit set switching Moves digits between Persian, Arabic-Indic and Latin Rules written for one set
Finglish Writes the same sentence in Latin letters Persian vocabulary filters
Mixed script Places lookalike Latin letters inside a Persian word String matching, and incomplete normalisation
Directional characters Separates what is displayed from what is received Human review, and logs that render raw
Indirect injection Puts the text in a document the application reads later Any control that only sits on the user message

Each row is one variable. The testing is only worth anything if you change one at a time; otherwise you cannot tell which change was the one that worked.

Testing a chatbot, step by step

1. List the ways text gets in. The user message is one. The name of an uploaded file, the body of a retrieved document, a ticket subject, the response from a third-party API, even an error message from another system are all text. Anything missing from this list does not get tested.

2. Establish a baseline. Send the simplest form of the instruction, in plain Persian, with no disguise. If that works, you are finished and the rest is unnecessary. If it does not, you now know what the filter catches.

3. Change one variable. Rewrite the same sentence using one shape from the table. Record the result. Return to the original and try the next.

4. Take the indirect route. Put the text in a document the application retrieves rather than in a message you send. Plenty of applications treat user input carefully and assume retrieved content is safe.

5. Ask what the model can do, not what it says. A model saying something it should not is one thing. A model calling a tool, changing a record or sending data outward is another. The severity of the finding comes from the model's access, not from the tone of its reply.

6. Repeat, and count. Model behaviour is probabilistic. One success does not prove the problem is always there and one failure does not prove it is absent. Run each test several times and report the result as a rate: three times in ten.

7. Read the logs. If the injected text cannot be found in the logs, or has been cleaned up so thoroughly that its original form is gone, the security team cannot reconstruct an attack after the fact. That is a finding in itself.

The defence: normalise first, then filter

Most teams hand the filter text in exactly the shape the user sent it. The right order is the other way around.

Normalise the text once, before any decision is made: map Arabic characters to their Persian equivalents, remove or surface the zero-width and directional characters, unify the digits, and apply a single Unicode normalisation form. Then run the filter and the classifier against that normalised text. Keep the original for display, so nothing is lost from what the person actually wrote.

Do it once, at the boundary. If every part of the application normalises on its own, the differences between them become the same gap you were trying to close.

Normalisation does not remove the attack. It closes the cheapest way around the filter, and it makes the filter you wrote see what the model sees.

Controls that do not depend on the model

Any defence that relies on the model behaving correctly may behave differently the day the model is updated. The durable controls sit outside it:

  • Least privilege for tools. The model reaches only what its task requires, through an allowlist rather than general trust.
  • Human approval for anything irreversible. Deletion, payment and sending data outside the organisation should not follow from a sentence in a ticket.
  • Model output is untrusted input. Wherever the output reaches a browser, a query or a command, treat it the way you treat input from a user.
  • Separate trust levels in retrieval. A document a customer uploaded is not the same as an approved internal one, and the application should know the difference.
  • Limits and monitoring. Rate limits on tool calls, and an alert when the pattern of use leaves its normal shape.

Common questions

Is an input filter enough? No. A filter is the first layer and its job is to make the simple attack expensive. As the only defence, one of the shapes above gets through it sooner or later.

Is a model that understands Persian better also safer? Not necessarily. Understanding the language better means understanding a disguised instruction better too. The question that matters is elsewhere: how much the application trusts the output, and what the model can reach.

How does indirect injection differ in a report? In the direct case the attacker has to talk to the application. In the indirect case it is enough to leave text somewhere the application reads later, and the victim does nothing unusual. The second usually carries the higher severity.

How do we report an attack that does not always work? As a rate. Record the number of attempts and the number of successes, with the model version and the date of the test, because both change with an update.

Does normalising break the user's text? Not if the normalised version is used only for decisions and the original is kept for display and storage.

If you run a chatbot, an internal assistant or an agent that reads text from outside, fill in that list of entry points once with your own team. There is usually one route on it that nobody had thought about.