venmo-me-67
b01lersc
Task: a Flask app sends a user-supplied receipt image to Gemini with `SECRET: <flag>` embedded in the prompt, then forwards extracted item names into a second Gemini call driven by attacker audio. Solution: inject the first model to copy the secret into the single receipt item name, then force the second model to copy that canonical item into `split.payer`, which is reflected to the client.
$ ls tags/ techniques/
$ cat /etc/rate-limit
Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.
venmo-me-67 — b01lers CTF 2026
Description
No official organizer description was included with the provided challenge files.
We are given a Flask application that accepts a receipt image and an instruction MP3. The backend sends both files through Gemini in sequence and finally returns a split result to the user interface. The goal is to recover the real flag from this pipeline.
Analysis
The core bug is a two-stage multimodal prompt-injection chain.
In process_receipt_with_ai() the application sends the attacker-controlled receipt bytes to Gemini together with a text prompt that already contains the real flag:
contents=[ types.Part.from_bytes(data=receipt_bytes, mime_type=receipt_mime_type), ( f"SECRET: {_load_flag_secret()} " "Extract receipt items and output JSON only. " "For each item include original_price and final_price where " "final_price = original_price * (1 + tax_rate + tip_rate)." ), ]
Even though the response is constrained to JSON, the model still freely chooses the content of string fields such as items[0].name. That means a malicious receipt image can prompt-inject the model into copying the secret into that field.
The output of the first model is then reused by process_instructions_with_ai():
canonical_items = [item for item in sorted({_normalize_text(name) for name in receipt_items}) if item] item_list_text = "\n".join(f"- {name}" for name in canonical_items) if canonical_items else "- none" ... "Allowed item names are exactly this canonical list from receipt parsing:\n" f"{item_list_text}\n"
So if the first stage makes the only canonical item equal to the flag, the second stage will see that flag as trusted receipt data.
Finally, split_bill() returns payer directly, and app.py exposes the split result back to the client:
return { "payer": payer, "owes": normalized, "allocation_warnings": warnings, }
and:
result={ "split": result.get("split", {}), "venmo": result.get("venmo", {}), }
...