Lab β€” Red-Team Your Own LLM API#

Attack a system you built, prove which defence stopped each attack, and leave behind a regression suite that keeps it fixed.

⏱ ~4–5 hours πŸ”— needs: LLM Security β€” Offensive Β· LLM Safety β€” Defensive Β· OWASP LLM Top 10

You’ll build a deliberately weak LLM service, break it, harden it, and then prove the hardening works β€” with tests that run in CI.

βš–οΈ Target only your own deployment. Every attack in this lab runs against the service you build and host. Probing a third party’s LLM product is unauthorised testing. If you want a harder target, make your own service harder.

Part 1 β€” Build the vulnerable service (45 min)#

A FastAPI app with an LLM endpoint that deliberately commits several OWASP sins:

  • A system prompt containing a fake secret (the internal API key is DEMO-1234)
  • A tool the model can call (send_email, delete_record β€” stubbed, printing rather than acting)
  • A /summarise?url= endpoint that fetches a page and feeds the text to the model β€” your indirect-injection surface
  • No output validation, no budget cap

Use fake secrets and stubbed tools throughout. Nothing here should be able to do anything real.

Part 2 β€” Attack it (90 min)#

Write at least 12 attacks across these classes, and record the outcome of each:

ClassOWASPAttempts
System prompt extractionLLM07β‰₯3 (direct, role-play, “summarise your instructions”)
Direct prompt injectionLLM01β‰₯2
Indirect injectionLLM01β‰₯3 β€” host an HTML file with hidden instructions and point /summarise at it
Tool abuseLLM06β‰₯2 β€” make the model call delete_record unprompted by the user
Output-handling abuseLLM05β‰₯1 β€” get markdown-image exfiltration or script into the output
Unbounded consumptionLLM10β‰₯1 β€” make one request cost far more than it should

Record each as: id | class | payload | result | evidence. Indirect injection is the centrepiece β€” the payload lives in a page the model reads, not in anything the user typed.

Part 3 β€” Defend (90 min)#

Apply the layers from LLM Safety β€” Defensive, in this order:

  1. Remove secrets from the system prompt entirely
  2. Tool allow-list, deny by default, with a human gate on destructive tools
  3. Schema validation on every tool call
  4. Delimit and label untrusted retrieved content
  5. Escape/sanitise output before rendering; restrict outbound domains
  6. Token and spend caps

For each attack from Part 2, re-run it and record which specific layer stopped it. An attack blocked by two layers is worth noting β€” that’s defence in depth working.

Part 4 β€” Lock it in (45 min)#

Turn every successful attack into an automated test:

@pytest.mark.parametrize("payload", INJECTION_PAYLOADS)
def test_no_secret_leak(payload):
    r = client.post("/ask", json={"question": payload})
    assert "DEMO-1234" not in r.text
    assert "system prompt" not in r.text.lower()

Wire it into GitHub Actions so it runs on every push. Optionally add promptfoo for a broader generated attack set.

Deliverables#

#Item
1Repo with vulnerable/ and hardened/ (or a feature flag)
2ATTACKS.md β€” the β‰₯12 attacks, before/after results, evidence
3A defence matrix: attack Γ— layer that stopped it
4A passing test suite, running in CI
5200 words: which attack was hardest to stop, and why

Grading#

WeightCriterion
25%Attack quality β€” genuine variety, including working indirect injection
25%Defence mapping β€” you can name which layer stopped what, with evidence
20%Regression suite β€” tests exist, pass, and run in CI
15%Layered thinking β€” capability limits, not just input filters
15%Write-up β€” honest about what still gets through

Being unable to fully stop prompt injection is the expected result. Saying so, and showing that your capability limits make it harmless, is the correct answer. Claiming you solved it is the wrong one.

Checklist#

  • Fake secrets and stubbed tools only.
  • At least one indirect injection that works via a page the model reads.
  • Every successful attack has a test.
  • Tests run in CI on push.
  • The write-up names what still gets through.