webfreemedium

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/
multimodal_prompt_injectioncross_llm_secret_exfiltrationschema_bypass_via_string_fieldsreflected_payer_exfiltration

$ 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", {}), }

...