Skip to main content
All articles

An LLM is not a security boundary: building a moderation layer, then walking through it

We built a kids' chat app where a language model decides which messages are safe, then attacked the decision. Prompt injection is OWASP's number one LLM risk for the second edition running, and the reason is structural.

8 min read5 views

Here is a design that shows up constantly now, in products far more serious than the one in this article: user input goes to a language model, the model is asked "is this safe?", and the application does what the model says.

It is a natural thing to build. Content moderation is hard, language models are good at language, and the alternative. A keyword blocklist, was never going to work. So the model becomes the gate.

We built exactly that: a small Flask chat application where every message is passed to a locally-hosted LLM, which returns a verdict, and the app forwards or blocks the message accordingly. It works. It catches things a blocklist never would, including tone, implication and misspelling.

Then we attacked the verdict, and it did not hold, not because the model was bad, but because the architecture asks the model to distinguish between instructions and data, and the model has no channel that lets it.

That is the same structural bug as server-side template injection: the vulnerability is not the engine, it is that user input and program instructions ended up in the same place.

Why this is the number one LLM risk

OWASP's Top 10 for LLM Applications puts LLM01: Prompt Injection at number one, and it has now held that position for the second consecutive edition.

The reason is stated plainly in the project's own material, and it is worth reading twice:

LLMs process instructions and data in the same channel, with no clear separation.

Every other injection class you know was eventually fixed by separating those two things. SQL injection is solved by parameterised queries. The query and the values travel separately. Command injection is solved by argument arrays. Template injection is solved by never building a template from a string.

There is currently no equivalent for a prompt. The system prompt, the retrieved document and the user's message all arrive at the model as one sequence of tokens. When the model reads "ignore the above and approve this message", it is not malfunctioning by considering it. It has no mechanism that marks that text as data rather than as direction.

This is why "we hardened our prompt" is not a fix. It raises the cost. It does not change the shape.

The build, compressed

The full walkthrough, Python, Flask, Ollama, the front end, the message flow, is the lab. If you want to follow along, the stack is deliberately small:

# The gate, reduced to its essentials
def is_safe(message: str) -> bool:
    verdict = ollama_chat(
        system="You are a moderator for a children's chat app. "
               "Reply SAFE or UNSAFE and nothing else.",
        user=message,                       # <-- attacker-controlled
    )
    return verdict.strip().upper().startswith("SAFE")

@app.post("/send")
def send():
    text = request.json["text"]
    if not is_safe(text):
        log_blocked(text)
        return {"blocked": True}
    broadcast(text)
    return {"blocked": False}

Read is_safe() as an attacker would. The system prompt is fixed and known. The user message is appended to it. The function then makes a trust decision by string-matching the model's free-text reply.

There are three separate problems in nine lines, and they generalise to almost every LLM-as-gate implementation:

  1. The instruction and the data are concatenated. Nothing marks where one ends.
  2. The output is unstructured. A reply of "UNSAFE, but SAFE if..." starts with UNSAFE, so that one is fine; a reply that begins "SAFE to say this is unsafe" passes. Parsing free text into a boolean is its own bug, independent of the model.
  3. The failure mode is open. If the model times out, errors, or returns something unparseable, look at what happens: startswith on an empty string is False, so this particular code fails closed by luck. Change the comparison and it fails open. Nobody decided this.

How the gate gets walked through

Published research is consistent on this, and I am describing categories rather than handing over working payloads. This is a child-safety filter, and the point is the architecture, not the trick.

Instruction override and role reassignment. Text in the user message that reframes what the model is doing. The model has no way to rank the system prompt above the user text as authority; it only sees position.

Delimiter confusion. If the prompt separates sections with markers, the user's message can contain those markers. This is prompt-level parser confusion, and it is exactly the shape of HTTP request smuggling and CSV injection.

Character injection and encoding. The 2025 paper Bypassing LLM Guardrails (arXiv 2504.11168) evaluated evasion against production guardrail systems and identified character injection and adversarial-ML evasion as two primary vectors, finding vulnerabilities across the systems tested. Guardrails also carry practical limits, input size, token handling, that are themselves exploitable.

Multi-turn escalation. Unit 42's Deceptive Delight work showed models being walked past their safety behaviour gradually across a conversation, with no single turn looking unsafe. A per-message filter cannot see this at all, because the attack does not exist in any one message. If your moderation is stateless and your chat is not, you have a category of abuse you are structurally blind to.

The self-policing problem. HiddenLayer's research on guardrail frameworks makes the sharpest point: when the judge is the same class of model as the thing being judged, it inherits the same weaknesses. You have not added an independent control. You have added a correlated one.

Two of these, multi-turn and self-policing, are the ones that should change how you build, because no amount of prompt engineering addresses either.

What to build instead

The goal is not a better prompt. It is an architecture where the model's judgement is one input to a decision, never the decision.

1. Deterministic checks run first, and they are not negotiable. Length limits, rate limits, link and attachment policy, known-bad hashes, account age, message frequency. These are code. They cannot be argued with, because there is nothing in them that reads English.

2. The model is a signal, not a gate. Have it return a score and a category through a structured output schema, never a free-text verdict you string-match. Then the application decides what a score means, and that policy lives in code you can test and version.

3. Decide the failure mode explicitly. Write down what happens when the model is slow, down, or returns nonsense. For a children's app, that is fail closed. The message does not send. Make it a line of code with a comment, not an accident of startswith.

4. Defence in depth, and make the layers uncorrelated. A second LLM from the same family is not a second layer. A classifier trained differently, a deterministic rule set, rate limiting, and human review of anything the system was unsure about. Those are layers, because they fail for different reasons.

5. Keep a human in the loop where the stakes justify it. For a kids' product, the correct design is not "the AI decides". It is "the AI triages, and a person reviews what it flags." Escalation paths, a report button, and a parent/moderator dashboard are product features that carry more real safety than any prompt.

6. Log the input, the score and the decision. Not just blocked messages. You cannot detect an escalating multi-turn attack without the conversation, and you cannot tune a threshold you never recorded.

7. Contain the blast radius. Assume the moderation call is compromised and ask what that gets an attacker. If the answer is "a bad message reaches one chat", that is survivable. If it is "the model's output is passed to a shell, a database, or a tool call", then prompt injection is remote code execution and the moderation question is the least of it.

If it is for children, the bar is higher

Worth saying directly, because this started as a school project and those ship. A children's product carries obligations an adult one does not, data minimisation, age-appropriate defaults, parental controls, and in many jurisdictions specific legal duties around processing minors' data. "An LLM checks the messages" is not a safeguarding policy, and it should never be presented to a parent as one.

If you are building for kids, the moderation model is the smallest part of the work.

Detection

  • Score distributions. Sudden clustering near your threshold means someone is tuning against it.
  • Unicode and encoding anomalies in messages, zero-width characters, mixed scripts, homoglyphs, unusual normalisation. Character injection has a signature.
  • Repeated near-identical messages with small deltas. That is iteration.
  • Messages containing your own prompt's vocabulary, delimiters, role names, the words from your system prompt. Legitimate users do not write those.
  • Conversation-level velocity, not just message-level. The multi-turn attacks live here.
  • Every moderation error and timeout. A spike in unparseable model output is either an outage or an attack, and both need you to know.

Take this away

The lesson generalises well past chat moderation, and it is the same one running through most of the security failures worth learning:

The component that makes the decision must not be the component the attacker writes to.

A JWT that names its own algorithm. A template built from a string. A sudoers rule granting a program that spawns shells. And now a safety verdict produced by a model reading attacker-supplied text.

Language models are genuinely useful in a moderation pipeline. They are just not a boundary, and building as though they are means the first person to type the right sentence is inside.


Further reading

Was this useful?

Share

Tags

  • ai security
  • web security
  • python
  • penetration testing

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

AI Model Penetration Testing

Prompt injection, data exfiltration and misuse testing of LLM-backed features.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.