I wrote gun for LakeCTF finals 2025-2026.

For my general feelings on the competition, see https://hazyclimb.dev/posts/lakectf-retrospective/ .
Authoring the challenge
I’ve been feeling uber unsatisfied with the state of Arbitrary Address Write (AAW) to Arbitrary Code Execution (ACE) techniques on glibc lately. The go-to resource for this has been https://github.com/nobodyisnobody/docs/tree/main/code.execution.on.last.libc (for good reason too), but it hasn’t been updated in 3 years. I have lots of notes and thoughts on this, but even then, I find myself rethinking this stuff often when some heap chall gives me a 0x10 sized AAW and I really don’t feel like making it into multiple writes.
Global Offset Table (GOT) spraying has been killed since glibc is Full RELRO, same for the link_map overwrite (that was so neat too). File Stream Oriented Programming (FSOP) is good but is quite complex so can be annoying to debug issues when you’re doing multi-shot stuff or don’t control null bytes or flags etc. Exit handlers are straight-forward but they require a leak either of the encrypted pointer itself or of ld, and ROP is in a similar situation (requiring an environ stack leak, and can also be messy w.r.t. messing up surrounding stack bytes).
God forbid you’re doing a small tcache write and the fields you need to overwrite don’t align with 0x10.
I think for 2026/srdnlen/linx I ended up going with stdout FSOP for environ leak into ROP, but spent a lot of time figuring out this was the way to go.
So anyway, I wanted to make a challenge about glibc ACE. In particular I started looking around at all the writable memory in libc to try to find something people might have missed before. Combing through everything, I saw a few things that seemed interesting, but ultimately couldn’t find anything usable.
I had the idea of having something gun-themed and doing bit flips in parallel (thought about doing some light crypto with RNG cracking, but decided against it (good thing too, the crypto players were already drowning)), so having not found something I liked, I thought a bit about how I could incorporate bit flips into an existing libc ACE. I really wanted to stay away from FSOP (which is why I use syscalls in the challenge source), so targeting initial aka __exit_funcs (used by __run_exit_handlers) came up quite quickly. I thought it was relatively neat that you could mess with it without leaking the pointer_guard from Thread Local Storage (TLS) (with bit flips).

Okay, so the clear idea is to edit the encrypted initial pointer (which usually points to _dl_fini) to point to a one_gadget. I wanted to constrain the bit flips as much as possible, so I played around with bruting it a bit and figured that it is doable in 8 bit flips.

I had spent some time researching guns (I didn’t know anything about them), because I really wanted to be thematic and accurate on the amount of bullets you have in the gun (being equal to the amount of bit flips available). I ended up settling on a Walther PPK. Then, one day before the competition, I realized that my solve script did not work on remote because I had a newer kernel version locally, affecting ASLR. So, the remote required 9 bit flips. It was annoying to have to fix this, but I’m also a bit glad that I did, since the Colt 1911 is a much cooler gun B) .
Anyway, I had a bit of a reality check when I realized that while I was researching glibc one of the patches I left in was

and that after undoing the patch, my one_gadget wasn’t satisfied anymore!
(Side-note: The pwndbg onegadget command is really awesome, it gives you exactly which conditions of which onegadget are and aren’t satisfied in the current moment in the execution, so you don’t have to check the conditions manually yourself.)
I felt shipping the glibc with the patch was in poor taste, so fuck it, let’s bit flip the glibc as well.
And so

the challenge idea was done.
The Challenge

If it wasn’t clear from the authoring experience, here is the setup of the challenge, it is devastatingly simple.
You get 1 bit flip in the glibc ELF file, another process gets spawned which uses that glibc. In that process you get a libc and ld leak, and you get 9 bit flips anywhere in the address space. The process exits normally, your goal is to get RCE.
You can see the source, handout & exploit of the challenge at https://github.com/polygl0ts/lakectf-25-26-public/tree/main/finals/pwn-gun .
The Writeup
The solution is as already described. The function pointer in initial is xor + shift encrypted with a secret value (pointer_guard) in the TLS. We don’t know what this value is, but we know that the unencrypted value is the address of _dl_fini (which is in ld, which we have), and we want to change this pointer to point to a known value (the one_gadget).
Ergo, we can use our 9 bit flips to change the pointer from _dl_fini to the one_gadget like this:

And we use our first glibc ELF bit flip to satisfy the one_gadget. I suppose there are a couple of ways, but in line with what my patch was doing, we change this mov ecx, 1 to mov ecx, 0 in exit():

Putting it all together:

See my solve script in the lakectf public repo or here.
Ending Flavour
I made the challenge print the ending differently depending on if you got the shell or not, I wonder if anyone noticed.
The failure message:

will not get printed if you get a shell and exit cleanly, and instead, by printing the flag you get “You exit the building alive. Corpses in your wake. Breathe.”
Testing
Since I kind of got to the solution of the challenge in a very backwards way (i.e. very differently than how a player would do it), I was concerned on how hard / guessy it was. So I was excited to have it tested.
P.Howe tested the challenge and absolutely chewed it up. His solution was to flip the glibc ELF file’s .text permission bits from R-X to RWX, and then to use the bit flips to change the GOT entry of exit() to point to main(), giving him infinite bit flips, and allowing him to write arbitrary shellcode into the libc code.
Whoops, I forgot to consider setting Full RELRO. Nevertheless, I was really enamoured by the .text bit flip idea, and am happy many players found it as well.
After some discussion the challenge underwent the following changes:
- Set the binary to Full RELRO so the GOT can’t be targeted
- Provide only libc and ld leak, instead of libc, ld, PIE and heap - in order to not distract the players too much
I didn’t want to leave P.Howe solution in the challenge as I felt:
- It was too easy
- Getting infinite bit flips strayed significantly away from the spirit of the challenge (which is the reason why I intentionally avoided providing a stack leak as well)
In the end his impression was that it was a neat easyish challenge.
Player Solutions
To my surprise the challenge didn’t have that many solves (5/10). Interestingly NaviVM had the same amount of solves, even though we considered it to be harder.
Let’s go over how the players solved the challenge.
keto from CyKor
Solved it the intended way! See their solve script and explanation here.
xlr8or from flagbot
Also solved it the intended way! See his solve script and explanation here.
Leo and a$h from ARESx
Also also solved it the intended way! See their solve script here.
With a nifty bit-check too:

corgo from Squid Proxy Lovers
Getting into the sauce now. See their solve script and explanation here.
Their explanation is very concise and clear so I will post it here verbatim:
use the first bit flip to change the permissions of the .text section from RX to RWX, now the next 9 bit flips can edit the code of any function.
for that, i stepped through all the code that gets run during exit (io cleanup, dl fini, exit handlers etc etc), and looked at every relative jump the program makes (je, jne etc etc). looking for one where A) i could bit-flip the jump from whatever it currently was to gets and B) RDI was a stack address, resulting in us calling gets(stack_address) letting us ROP to a shell. the one i used was this:
0x8975d <__GI__IO_flush_all+749>: jne 0x894f0 <__GI__IO_flush_all+128>
which gets bit flipped to this:
0x7661f768275d <__GI__IO_flush_all+749>: jne 0x7661f76746b4 <_IO_gets+4>
Wauuu. Nice idea! I hadn’t thought of that!
Hehe I intentionally made the program interaction straightfoward by annotating every prompt with >, but you still went with

Hey, as long as it works :7 .
flyyee and samuzora from NUS Greyhats
Also very creative! See their solve script and explanation here.
I will let the players speak for themselves again:
first bit flip: change flags for the .text segment in libc from r-x (5) to rwx (7)
now that .text is writable, we can modify the gadgets that we are using
we had the idea to modify the function epilogue of one of the functions called on exit such that the new stack frame will align with our user input and we can ROP from there
but because we needed to offset the frame by more than 0x80 (which wld require a different instruction length), we ended up having to use both the function prologue and epilogue of __call_tls_dtors
then we had a one_gadget that had constraints on r9 and rcx being 0, but rcx cannot be 0 before our call to __call_tls_dtors because that would skip calling __call_tls_dtors in the first place (because of the arguments to __run_exit_handlers)
so we modify the one_gadget to change that constraint from rcx to rdx
For clarity, here is the most important part of their nicely commented solve script:

Here is what the __call_tls_dtors function looks like after their modifications:

In the ninth take_coord, they send the ROP chain (stored in a stack buffer). This is not hex so they enter the early exit of
ERR("You fumble with your handgun for far too long.");
exit(1);
which eventually goes into __call_tls_dtors. There they move RSP exactly up to their ROP chain, and the rest is history.
Pretty interesting here that they exploit the fact that __call_tls_dtors does not have a normal leave / ret pattern, but rather has a prolog / epilog like this:
pwndbg> disass __call_tls_dtors
Dump of assembler code for function __GI___call_tls_dtors:
=> 0x00007f6d25abdee0 <+0>: push rbp
0x00007f6d25abdee1 <+1>: push rbx
0x00007f6d25abdee2 <+2>: sub rsp,0x8
# [snip..]
0x00007f6d25abdf3e <+94>: add rsp,0x8
0x00007f6d25abdf42 <+98>: pop rbx
0x00007f6d25abdf43 <+99>: pop rbp
0x00007f6d25abdf44 <+100>: ret
End of assembler dump.
Also, on my Arch-provided glibc:
pwndbg> disass __call_tls_dtors
Dump of assembler code for function __GI___call_tls_dtors:
0x00007ffff7c40d00 <+0>: endbr64
0x00007ffff7c40d04 <+4>: push rbp
0x00007ffff7c40d05 <+5>: mov rbp,rsp
0x00007ffff7c40d08 <+8>: push r12
# [snip..]
0x00007ffff7c40d5f <+95>: pop rbx
0x00007ffff7c40d60 <+96>: pop r12
0x00007ffff7c40d62 <+98>: pop rbp
0x00007ffff7c40d63 <+99>: ret
End of assembler dump.
Heh, I guess the compilation options that Arch uses make it look different than my default-options locally built glibc. Didn’t realize that :D ! But it enabled these shenanigans so honestly I’m happy.
This is definitely the most implementation-detaily solve, but a fun one nonetheless :P
Conclusion
I loved that teams had varying solutions, and it was immensly fun to talk to them about it after the competition.
The intended solution definitely required thinking backwards, i.e. thinking about the 9 bit flips first, then the 1 ELF flip, which is the opposite of the order they appear in the challenge. I wonder if people had the insight to actively consider both directions, or if it was more of an accident on what first came to mind to which player.
I find it endearing that for this challenge, writing the exploit itself can be trivial if you catch the right idea. I feel this is not something you see often in pwn!
Thank you everyone for playing, hope you had fun!
Good luck at DEF CON quals; stardew-valley writeup coming soon…