What was the challenge?
The application accepted a filename, an M3U8 playlist, and a JWT. A valid admin token
was required before the playlist was written to disk and processed. The goal was to
retrieve a flag stored in /tmp/flag.txt.
The Dojo settings exposed the setup and application source, so the first step was to map the files and follow the request from authentication to playlist processing.
1. User-controlled JWT key selection
The verifier read kid from the unverified JWT header and inserted it directly
into a filesystem path. There was no allowlist, path normalization check, or fixed map
between key identifiers and key material.
def load_key(kid: str) -> bytes:
with open(f"/tmp/keys/{kid}.txt", "rb") as f:
return f.read()
def verify_token(token: str) -> dict:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
key = load_key(kid)
return jwt.decode(token, key, algorithms=["HS256"])
Supplying ../README as the identifier changed the resolved key path from the
intended directory to a known file:
/tmp/keys/../README.txt → /tmp/README.txt
2. Turning a predictable file into the signing key
Path traversal alone was not enough: the selected file also needed predictable content so that I could reproduce the server's HMAC signature. The setup code created the README at a fixed path with a fixed string:
streamcore is a new project developed to handle u3m8 files more easily.
That string became the HS256 secret. I could therefore sign a token with
kid set to ../README and the visible authorization claim set to
{"isadmin": true}. The server loaded the same README content and accepted
the forged signature.
Header: {"alg":"HS256","kid":"../README"}
Payload: {"isadmin":true}
Secret: streamcore is a new project developed to handle u3m8 files more easily.
3. Local file inclusion through the HLS manifest
After authentication, the application wrote the supplied playlist to
/tmp/<filename>.m3u8 and passed it to Streamlink. The attacker controlled
the manifest body, including its segment URI.
filename_path = f"/tmp/{filename}.m3u8"
with open(filename_path, "w") as f:
f.write(content)
streams = streamlink.streams(f"hls://file:///{filename_path}")
fd = streams["best"].open()
chunk = fd.read(1024 * 1024).decode("UTF-8")
A minimal playlist could point its media segment at the local flag. Streamlink followed
the file:// URI, and the application returned the bytes it read.
#EXTM3U
#EXT-X-TARGETDURATION:10
#EXTINF:10.0,
file:///tmp/flag.txt
#EXT-X-ENDLIST
4. The complete chain
These were the steps in my final request:
- Set the JWT
kidto../README. - Sign an admin claim with the known README content.
- Submit the forged token with an M3U8 manifest referencing the flag.
- Let Streamlink read the local file as the playlist's media segment.
{
"filename": "exploit",
"content": "#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:10.0,\nfile:///tmp/flag.txt\n#EXT-X-ENDLIST",
"token": "<JWT signed with the known README content>"
}
The authentication bypass reached the privileged processing path, and the manifest then turned that access into an arbitrary local-file read.
Impact
- Authentication bypass: a forged admin JWT granted access to privileged functionality.
- Local-file read: any file readable by the application process could potentially be returned through a crafted manifest.
- Combined impact: secrets, configuration, keys, and other local application data could become reachable.
Remediation
- Map JWT key identifiers to fixed keys instead of composing file paths from header values.
- If file-backed keys are required, allowlist identifiers and verify the resolved path remains inside the key directory.
- Reject local-file and other unapproved URI schemes in user-supplied HLS manifests.
- Process untrusted media in a sandbox without access to application secrets or sensitive filesystem paths.
Key lessons
- An unverified JWT header must never directly control key lookup.
- A predictable local file can become key material when path traversal reaches it.
- File validation must account for the behavior of the downstream parser.
- Chaining modest primitives can create a complete authentication-bypass and data-read path.