back to research
LARP-2026-007ctf3 min read

Chaining four small bugs in a Go service from a length wrap to CRLF

Four small bugs in one Go service, none of them worth much alone: a 16-bit length field that wraps, SQL injection through identifiers nobody filtered, YARA running as root used to read a file byte by byte, and CRLF in a user agent to turn a GET into a POST.

This one is from the AFRICC 2026 qualifiers, and it turned into a chain of several small bugs that only became useful when combined. I’m writing it up for the ideas more than the challenge, because every step in it shows up outside CTFs too.

Setup

The site asked only for a System Handle to log in, no password. Once in, the main feature was a search box that took a file path and returned information about the file. The source archive showed one Go program running three servers:

textports
8000  public pages
5000  internal /query and /upload
5002  /verify

A second container, flagserver, sat on an internal-only network. The credential it wanted lived in /opt/vault/credentials.dat, owned by root with mode 0400.

Length field wrap

The frontend talked to the internal query endpoint with a small binary protocol, and each field was prefixed with its length as a uint16. That type stops at 65535, and nothing stopped the value from being longer.

Send 65536 + N bytes and the length that goes on the wire is just N:

goillustration, not the challenge code
hdr := make([]byte, 2)
binary.BigEndian.PutUint16(hdr, uint16(len(value))) // 65536 + N is written as N
buf.Write(hdr)
buf.Write(value)

The backend reads N bytes as the value and parses everything after them as the next fields. So the tail of my oversized value became the table and column names. It’s a framing bug, the same family as request smuggling: the length on the wire and the bytes on the wire stop agreeing, and the receiver believes the length.

Query injection

The backend built its query with a format string:

sqlquery template
SELECT * FROM %s WHERE %s='%s';

There was a filter for single quotes, but it only applied to the value. The table and column were assumed to be trusted, because normally the frontend wrote them. With the length wrap I wrote them, so I could inject through the column. For the login I used a column expression equivalent to 1=1--, which turned the condition into one that matched everything.

Real code does this a lot. Identifiers can’t be bound as query parameters, so people filter the value and trust the table and column. They should come from an allow-list, never from anything the client can reach, even indirectly.

YARA as a file oracle

The app could run YARA rules, and the scanning ran as root, so it could read the vault file my user couldn’t. A YARA rule is a yes or no question about a file. Ask about one byte at a time and you can read the whole thing.

The credential was 64 hex characters. For every position I sent 16 rules, one per hex digit, and kept the one that matched:

yaraillustration: is byte 17 an 'a'?
rule pos17_a {
  condition: uint8(17) == 0x61
}

Sixty-four positions at up to 16 rules each is at most 1,024 requests, and that recovered the full credential.

The same trick works with anything that runs your match logic with more privileges than you have: YARA, regex search, filter expressions. If you get a boolean back, you can read the data one piece at a time.

GET to POST

The flag server wanted the credential in a POST body. The app’s fetch feature only made GET requests through libcurl, but its user_agent field was a real input, and libcurl copied it straight into the request headers. CRLF in that field ends the User-Agent header, and after that I could write the rest of the request myself, including a second request on the same connection:

textuser_agent value, as it went on the wire
osq\r\n
Host: flagserver:5001\r\n
\r\n
POST / HTTP/1.1\r\n
Host: flagserver:5001\r\n
Content-Length: 64\r\n
\r\n
<credential>

The flag server accepted the credential and generated a temporary claim path, and I could finally use the normal GET to fetch the flag from it.

Full chain

textfull chain
length wrap           control the query's table and column
SQL injection         log in, query internal data
YARA as root          read the root-only credential byte by byte
CRLF in user agent    smuggle a POST to the internal flag server
GET the claim path    flag

The interesting part was how little any one of these did on its own. It was basically four small mistakes that were designed to fit together.

cuddest
back to research