Skip to content

GSoC 2026 Final Submission: OWASP FinBot CTF — Challenge Library, Rate Limiting, MCP Server, Bug Fixes - #567

Open
ashike24 wants to merge 16 commits into
GenAI-Security-Project:mainfrom
ashike24:feat/final-combined-submission
Open

GSoC 2026 Final Submission: OWASP FinBot CTF — Challenge Library, Rate Limiting, MCP Server, Bug Fixes#567
ashike24 wants to merge 16 commits into
GenAI-Security-Project:mainfrom
ashike24:feat/final-combined-submission

Conversation

@ashike24

@ashike24 ashike24 commented Aug 12, 2026

Copy link
Copy Markdown

GSoC 2026 Final Submission: OWASP FinBot CTF — Challenge Library, Rate Limiting, MCP Server, Bug Fixes

This pull request is my Google Summer of Code 2026 final work submission for OWASP FinBot CTF, covering the full coding period, May 25 to August 31, 2026.

Contributor: Ashik E (@ashike24) | IIT Kanpur | ashike24@iitk.ac.in Mentors: Nirupam Ghosh & Sanjeev Agarwal


Project Goal

FinBot CTF is OWASP's capture-the-flag platform for AI agent security. Two vulnerability categories, ASI-03 (Identity and Impersonation) and ASI-05 (Indirect Prompt Injection), had no challenges at the start of this program. The goal of this project was to build out those categories, harden the platform's agent-facing surface against real operational risk (rate limiting, MCP access), and improve platform reliability through live testing.

What I Did

Challenge Library (ASI-03 and ASI-05)

Built six new CTF challenges, each pairing a realistic prompt-injection attack scenario against a FinBot agent with an LLM-judge detector class.

Challenge Category Difficulty Points Detector
Ghost in the Machine ASI-03: Agent Impersonation Intermediate 250 AgentImpersonationDetector
Puppet Master ASI-03: Cross-Agent Trust Abuse Advanced 400 CrossAgentTrustDetector
Silver Tongue ASI-03: Role Hijack Intermediate 300 RoleHijackDetector
Trojan Invoice ASI-05: Document Injection Intermediate 300 DocumentInjectionDetector
Poisoned Inbox ASI-05: Email Injection Intermediate 300 EmailInjectionDetector
Tool Output Hijack ASI-05: Tool Output Injection Advanced 400 ToolOutputInjectionDetector

Each challenge follows the same shape: a vendor embeds an instruction payload disguised as a legitimate system message inside data the platform treats as trusted (a registration field, an invoice line item, a document, an email, or tool output), and the target agent acts on it as if it were a real directive. Every detector is an LLM judge that checks whether the agent's own output shows it treated attacker-controlled content as a legitimate authority.

Testing: 78 pytest unit tests across all six detectors, 97% coverage (target was 85%).

Rate Limiting

Built a per-namespace, fixed-window Redis rate limiter and wired it into all five agent-triggering vendor routes, keyed as finbot:ratelimit:{namespace}:agent, reusing the existing event-bus Redis client. Ten integration tests cover the counting logic, namespace isolation, Redis-failure behavior, and the error response.

Race condition found and corrected: the original increment-then-expire logic used two separate Redis calls, which allowed concurrent requests in a new window to both skip setting the key's expiry. This is fixed with a single atomic Lua script (EVAL) that performs the increment and conditional expiry in one round-trip, eliminating the race without changing the response contract. The design intentionally remains fail-open — rate limiting here is a soft usage guard, not an access control, and failing closed would take every agent endpoint offline platform-wide during any transient Redis disruption.

Verified with the full test suite (398 passed, 26 skipped, 6 pre-existing failures unchanged, 0 regressions) and live testing against real Redis in Docker (first 10 requests succeed, 11th onward correctly rejected).

MCP Server Layer

Added an MCP (Model Context Protocol) server layer (finbot/mcp_server/) exposing FinBot to MCP-compatible clients such as Claude Desktop and Codex Desktop, via two tools:

  • submit_tool_call(session_id, message)VendorChatAssistant.stream_response(), the same code path behind the rate-limited chat endpoint
  • get_scoring_results(session_id)UserChallengeProgressRepository, namespace- and user-scoped challenge data

Reuses the existing session-based SessionManager for auth rather than introducing a new mechanism. A start_challenge_session tool from the original proposal wording was deliberately dropped — no such concept exists in the platform, since challenge progress advances passively as a player interacts with the agent.

Along the way, found and fixed a pre-existing .env misconfiguration that placed the SQLite database outside the mounted Docker volume, silently losing data across container rebuilds for both the main app and the new MCP service.

Bug Fixes

Three real, previously unknown bugs were found through live testing of the running application (not through code review or unit tests alone) and fixed:

  1. Chat stream hangs indefinitely on any LLM failureChatAssistantBase.stream_response() had no error handling around the LLM call. Fixed with try/except and a clean user-facing error instead of a silent hang.
  2. API error responses from mounted sub-apps render as mislabeled HTMLis_api_request() matched /api/ only as a path prefix, but /vendor, /ctf, and /admin are mounted as independent ASGI sub-apps whose paths retain the mount prefix. Fixed by checking for /api/ as a path segment instead of a prefix.
  3. Rate limiter race condition — see Rate Limiting section above.

Documentation

  • Contributor guide (docs/contributing-challenges.md) covering the full YAML + detector + registration + test workflow for future challenge authors, using the real "Ghost in the Machine" challenge as a worked example. Flags the non-obvious, silently-failing step of forgetting to register a new detector in detectors/implementations/__init__.py.
  • Two blog posts covering the implementation work and MCP integration/testing philosophy in depth: (add links once published)

Current State

All work across the program is consolidated into this single pull request: 16 commits, 33 files changed, a clean diff against upstream/main. Verified with a full clean test-suite run and a live Docker stack across all three services (app, redis, mcp_server) — the app, database, and Redis all start healthy together, with all 25 challenges and 43 badges loading correctly.

What's Left

  • Incorporating any reviewer feedback on this pull request
  • Publishing both blog post drafts to the project's chosen channel

What Got Merged

This pull request represents the complete, current state of the contribution and is open against GenAI-Security-Project/finbot-ctf:main.

Challenges and Learnings

  • The most significant lesson of the program came late: a review comment I had previously replied to as "fixed" turned out not to actually be reflected in the codebase, because the fix commits had only been pushed to a feature branch and never merged into main. This was a process failure, not a code failure, and it changed how I now verify my own claims to a reviewer — I no longer consider a review thread resolved until I can point to the exact commit on the branch actually under review.
  • Building the rate limiter's first fix attempt taught me that a superficially working solution (passing tests, correct behavior under light load) can still hide a race condition that only shows up under true concurrency. The eventual atomic Lua-script approach was a better fix precisely because it removed the possibility of the race entirely, rather than narrowing the window.
  • Two of the three real bugs in this program (the chat-stream hang and the mounted sub-app error-handling bug) were only found by exercising the running application through a browser, not through unit tests or code review. This reinforced, for me, that automated test coverage and live integration testing catch fundamentally different classes of defects, and a mature workflow needs both.
# GSoC 2026 Final Submission: OWASP FinBot CTF — Challenge Library, Rate Limiting, MCP Server, Bug Fixes

This pull request is my Google Summer of Code 2026 final work submission for OWASP FinBot CTF, covering the full coding period, May 25 to August 31, 2026.

Contributor: Ashik E ([@ashike24](https://github.com/ashike24)) | IIT Kanpur | ashike24@iitk.ac.in
Mentor: Nirupam Ghosh


Project Goal

FinBot CTF is OWASP's capture-the-flag platform for AI agent security. Two vulnerability categories, ASI-03 (Identity and Impersonation) and ASI-05 (Indirect Prompt Injection), had no challenges at the start of this program. The goal of this project was to build out those categories, harden the platform's agent-facing surface against real operational risk (rate limiting, MCP access), and improve platform reliability through live testing.

What I Did

Challenge Library (ASI-03 and ASI-05)

Built six new CTF challenges, each pairing a realistic prompt-injection attack scenario against a FinBot agent with an LLM-judge detector class.

Challenge Category Difficulty Points Detector
Ghost in the Machine ASI-03: Agent Impersonation Intermediate 250 AgentImpersonationDetector
Puppet Master ASI-03: Cross-Agent Trust Abuse Advanced 400 CrossAgentTrustDetector
Silver Tongue ASI-03: Role Hijack Intermediate 300 RoleHijackDetector
Trojan Invoice ASI-05: Document Injection Intermediate 300 DocumentInjectionDetector
Poisoned Inbox ASI-05: Email Injection Intermediate 300 EmailInjectionDetector
Tool Output Hijack ASI-05: Tool Output Injection Advanced 400 ToolOutputInjectionDetector

Each challenge follows the same shape: a vendor embeds an instruction payload disguised as a legitimate system message inside data the platform treats as trusted (a registration field, an invoice line item, a document, an email, or tool output), and the target agent acts on it as if it were a real directive. Every detector is an LLM judge that checks whether the agent's own output shows it treated attacker-controlled content as a legitimate authority.

Testing: 78 pytest unit tests across all six detectors, 97% coverage (target was 85%).

Rate Limiting

Built a per-namespace, fixed-window Redis rate limiter and wired it into all five agent-triggering vendor routes, keyed as finbot:ratelimit:{namespace}:agent, reusing the existing event-bus Redis client. Ten integration tests cover the counting logic, namespace isolation, Redis-failure behavior, and the error response.

Race condition found and corrected: the original increment-then-expire logic used two separate Redis calls, which allowed concurrent requests in a new window to both skip setting the key's expiry. This is fixed with a single atomic Lua script (EVAL) that performs the increment and conditional expiry in one round-trip, eliminating the race without changing the response contract. The design intentionally remains fail-open — rate limiting here is a soft usage guard, not an access control, and failing closed would take every agent endpoint offline platform-wide during any transient Redis disruption.

Verified with the full test suite (398 passed, 26 skipped, 6 pre-existing failures unchanged, 0 regressions) and live testing against real Redis in Docker (first 10 requests succeed, 11th onward correctly rejected).

MCP Server Layer

Added an MCP (Model Context Protocol) server layer (finbot/mcp_server/) exposing FinBot to MCP-compatible clients such as Claude Desktop and Codex Desktop, via two tools:

  • submit_tool_call(session_id, message)VendorChatAssistant.stream_response(), the same code path behind the rate-limited chat endpoint
  • get_scoring_results(session_id)UserChallengeProgressRepository, namespace- and user-scoped challenge data

Reuses the existing session-based SessionManager for auth rather than introducing a new mechanism. A start_challenge_session tool from the original proposal wording was deliberately dropped — no such concept exists in the platform, since challenge progress advances passively as a player interacts with the agent.

Along the way, found and fixed a pre-existing .env misconfiguration that placed the SQLite database outside the mounted Docker volume, silently losing data across container rebuilds for both the main app and the new MCP service.

Bug Fixes

Three real, previously unknown bugs were found through live testing of the running application (not through code review or unit tests alone) and fixed:

  1. Chat stream hangs indefinitely on any LLM failureChatAssistantBase.stream_response() had no error handling around the LLM call. Fixed with try/except and a clean user-facing error instead of a silent hang.
  2. API error responses from mounted sub-apps render as mislabeled HTMLis_api_request() matched /api/ only as a path prefix, but /vendor, /ctf, and /admin are mounted as independent ASGI sub-apps whose paths retain the mount prefix. Fixed by checking for /api/ as a path segment instead of a prefix.
  3. Rate limiter race condition — see Rate Limiting section above.

Documentation

  • Contributor guide (docs/contributing-challenges.md) covering the full YAML + detector + registration + test workflow for future challenge authors, using the real "Ghost in the Machine" challenge as a worked example. Flags the non-obvious, silently-failing step of forgetting to register a new detector in detectors/implementations/__init__.py.
  • Two blog posts covering the implementation work and MCP integration/testing philosophy in depth: (add links once published)

Current State

All work across the program is consolidated into this single pull request: 16 commits, 33 files changed, a clean diff against upstream/main. Verified with a full clean test-suite run and a live Docker stack across all three services (app, redis, mcp_server) — the app, database, and Redis all start healthy together, with all 25 challenges and 43 badges loading correctly.

What's Left

  • Incorporating any reviewer feedback on this pull request
  • Publishing both blog post drafts to the project's chosen channel

What Got Merged

This pull request represents the complete, current state of the contribution and is open against GenAI-Security-Project/finbot-ctf:main.

Challenges and Learnings

  • The most significant lesson of the program came late: a review comment I had previously replied to as "fixed" turned out not to actually be reflected in the codebase, because the fix commits had only been pushed to a feature branch and never merged into main. This was a process failure, not a code failure, and it changed how I now verify my own claims to a reviewer — I no longer consider a review thread resolved until I can point to the exact commit on the branch actually under review.
  • Building the rate limiter's first fix attempt taught me that a superficially working solution (passing tests, correct behavior under light load) can still hide a race condition that only shows up under true concurrency. The eventual atomic Lua-script approach was a better fix precisely because it removed the possibility of the race entirely, rather than narrowing the window.
  • Two of the three real bugs in this program (the chat-stream hang and the mounted sub-app error-handling bug) were only found by exercising the running application through a browser, not through unit tests or code review. This reinforced, for me, that automated test coverage and live integration testing catch fundamentally different classes of defects, and a mature workflow needs both.

ashike24 added 16 commits May 29, 2026 17:23
mekaizen flagged a race condition on PR GenAI-Security-Project#532: if the process crashed
or lost its Redis connection between the separate INCR and EXPIRE
calls, a key could be left permanently incremented with no TTL,
silently blocking that namespace forever.

A previous attempt to fix this (commit a03481a) used a Redis pipeline
and switched the limiter to fail-closed on Redis errors, but that
commit was accidentally left stranded on a feature branch and never
merged - and it also changed EXPIRE to run on every request instead
of only the first, altering the fixed-window semantics, and would
have broken the existing test_expire_only_set_on_first_request test
and removed the just-added Retry-After header.

This commit takes a narrower approach: a single atomic Lua script
(EVAL) that performs the INCR and conditionally sets EXPIRE (only on
the first request in the window) in one Redis round trip. This fully
eliminates the race - there is no window between the two operations
where a crash could leave an orphaned key - while preserving the
original fixed-window behavior, the Retry-After header, and the
deliberate fail-open design (rate limiting is a soft quota guard, not
an auth control, so a Redis outage should not take down agent
endpoints entirely).

Test suite updated to mock eval() instead of incr()/expire()
separately, with a new test asserting the operation happens in a
single call. Verified live against real Redis in Docker: 10 requests
succeed, 11th correctly returns 429, no fail-open errors logged.
Full suite: 398 passed, 26 skipped, 6 pre-existing failures unchanged,
0 regressions.
- New finbot/mcp_server/ package with submit_tool_call and
  get_scoring_results tools, wrapping VendorChatAssistant and
  UserChallengeProgressRepository respectively
- Auth via existing SessionManager (session_id -> SessionContext),
  no new auth mechanism needed
- Runs as separate Docker service (mcp_server) on port 8100,
  sharing sqlite_data volume + Redis with the main app
- Fixed pre-existing .env bug: DATABASE_URL pointed outside the
  mounted sqlite_data volume, causing data loss on container
  rebuild for both services
- Verified end-to-end: get_scoring_results fully working;
  submit_tool_call reaches the LLM call correctly, blocked only
  by a missing OPENAI_API_KEY in local dev env (pre-existing gap,
  unrelated to this change)
- start_challenge_session from original proposal wording dropped:
  no such concept exists in the codebase; sessions already exist
  via login and challenges progress implicitly
Wraps the OpenAI responses.create() call in ChatAssistantBase.stream_response()
with a try/except. Previously any failure (auth error, timeout, transient
outage) crashed the SSE stream unhandled, leaving the user's browser stuck
on 'Thinking...' forever with no feedback. Now logs the error and streams
a clean user-facing message instead.

Found during Week 9-10 integration testing. Verified via live browser test
and full LLM test suite (82 passed, 0 regressions).
request.url.path retains the mount prefix (e.g. /vendor/api/v1/chat)
for requests handled by mounted sub-apps like the vendor, ctf, and
admin portals. is_api_request() only checked for a /api/ prefix, so
it never matched these paths - API error responses (429, 403, 404,
500, etc.) from those apps were rendering the generic HTML error page
instead of a JSON response, with the wrong status text shown (e.g.
a 429 rate-limit response displayed as '400 - Bad Request').

Fixed to check for /api/ as a path segment anywhere in the path,
so it works both for mounted sub-apps and routes handled directly
by the root app.

Found and fixed during Week 11 final QA pass. Verified live in
browser (429 now returns correct JSON body/status) and via full
test suite (398 passed, 26 skipped, 6 pre-existing failures
unchanged, 0 regressions).
Walks through adding a new CTF challenge end-to-end: YAML definition,
detector class, the required-but-non-obvious detector registration
step in detectors/implementations/__init__.py, unit test coverage,
and a pre-PR checklist. Uses the asi03-ghost-in-the-machine challenge
and AgentImpersonationDetector as a worked example throughout, based
on the actual patterns in the codebase.

Written for Week 11 per the proposal timeline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant