PascalCTF 2026 - YetAnotherNoteTaker
This one is a classic challenge when you have to pop a shell or execute another OS command that allows to read the `flag` file.
Teh vulnerability
This one is a classic challenge when you have to pop a shell or execute another
OS command that allows to read the flag file. The pseudocode of the challenge
is shown below:
| |
Note that we’ve got a format string vulnerability[1] at the line 30, and that’s the one we are going to take the advantage of.
Teh exploitation strategy
With a format string vuln like this we can do pretty much everything: leak memory from the stack, or write to an arbitrary address. The question is what are we going to do with these great powers? Let’s first see what binary protection mechanisms are enabled:
| |
The important bits here are as follows:
0. The binary is x64 (the addresses are 8 bytes long and such).
1. `Full RELRO` - there's no writable `.plt.got` section so we can fuck off with that [2].
2. `Canary found` - not that we care, but there are stack canaries enabled [3].
3. `NX enabled` - the W^X mechanism is on (e.g., sections that are writable are
non-executable, and vice versa); this essentially means we can't place any
shellcode on the stack/heap or other interesting places [4]
4. `No PIE` - typical, but still good - this one means you know the virtual
addresses within the sections such as .text, .bss and others at runtime (same
as in the binary) [3]
5. It is also very typical to expect that `ASLR`[5] is enabled (and it often
is), which means you can't know the base address of the stack, heap, and
library loads at runtime.
Typically, what we want to achieve is redirecting the control flow of the
program to the system("/bin/sh") function that will give us the shell. With
format string vulnerabilities we can write anywhere we want so we can just
overwrite the return address of the main() function to point to system() and
that’s it. However, there are the following problems:
1. We don't know the address of the `system` function (hello `ASLR`).
2. And even if we knew it, we can't pass arguments into `system()`.
To summarize, we can write to an arbitrary address on the heap, stack, and other writable sections, we just don’t know where and what. No matter, our friend format string got us covered. Also, say hello to your new friends: Return-to-libc [6], and Return-oriented programming [7].
The format strings like %N$p allows to print addresses right from the stack,
where N is the number of the positional argument to printf() (remember that
this function takes a variable number of arguments, and they are supposed to be
on the stack, so if there are no guardrails around the format string, we can
read the stack as far as we want).
First, we need to understand where the return address is located on the stack
(we break somewhere in main() and check like so):
| |
Here, 0x78de48620840 is the return address (after main() exits, the control
flow will be redirected there), and 0x7ffef2f087d8 is the address on the stack
at which this return address is located.
Now, we need to leak this somehow. To choose the right number for the stack
memory (N) location(s) we want to leak, we break right before we are about to
call printf (line 30 in the pseudocode) and print the contents of the stack like
so:
| |
Unlike x86, where all the printf() arguments are stored on the stack, on
x64 these are pushed into the stack only starting from the sixth argument.
Eventually, we can see that the 43rd argument is an address in libc, and the
45th argument is an address on the stack. To confirm, we can check see the base
of the stack and libc in gdb:
| |
Now, to leak these two addresses all we need to do is to craft the following input:
| |
As I already mentioned, you can do arbitrary writes with format string bugs. I
am not going to explain it here, but there are great resources out there [1]. If
you are using pwntools, you can take advantage of the
pwnlib.fmtstr.fmtstr_payload()[9] function to make your life much easier. All
you need to know is the offset, the address you are about to overwrite and the
address you want to be written. To figure out the offset you should do something
like this:
| |
You see, that out input (0x4141414141414141) appears at the 8th position, so
the offset will be 8.
| |
Keep in mind that we want to overwrite the return address 0x78de48620840
with the address of system(). To do that we need to calculate a few things:
The address on the stack where the return address is stored. Since the stack layout is deterministic in a way (the base address changes between runs due to
ASLR, but offsets stay the same), by leaking one stack address at a specific location we can reconstruct the rest. In this case, we leak the 2nd argument of themain()func -0x7ffef2f088b8. In gdb we can see that: *0x7ffef2f088b8 - 0x7ffef2f087d8 = 0xe0So to compute the stack address where the return addres will be stored, we need to: *ret_addr_addr = 45th_arg - 0xe0The address of the
system()function in libc at runtime. For example, the 43rd argument ofprintf()we leaked is0x78de48620840- this seems to be some function in libc, and it can help us to determine the libc base at runtime. For example, by computing0x78de48620840 - libc basewe get the offset0x20840, which happens to be an address in thelibc_main()function (check thelibc.so.6file). In order to get the address of system we can do: *system_addr = (43rd_arg - 0x20840) + system_offsetThe offset of the
system()function happens to be0x453A0(again, check thelibc.so.6file), so for the sake of this example, the virtual address of thesystem()function will be: *system_addr = (0x78de48620840 - 0x20840) + 0x453A0 = 0x78de486453a0Ok, so we can beat the
ASLRand redirect the control to where we want, now what? We still need to pass the right argument tosystem()- the “/bin/sh” string, and that’s the tricky part!
To do that last bit, we need a way of passing the input into the program, but
with the current program that’s impossible. We can still use the powerful format
string primitive, as the string “/bin/sh” happens to be present in the libc too
at the offset 0x18CE57:
| |
You guess it, we can compute the address of this string in the virtual memory in
the same way we computed the address of system() ;-)
There’s one last thing in the way: how do we pass that string address as an
argument to system()?
First off, remember that system() takes only one argument, which should be a
pointer to a string (e.g., char *) and that in x64 the first argument must
be located in the rdi register [8]. To achieve that we can use a bit of
Return-oriented programming [7] and place the pointer to “/bin/sh” on the stack,
then pop it, and return system(), so we should do something like:
| |
The problem is, there are no such gadgets that pop the rdi register. The closest we can find at first is this:
| |
Fret not, there’s a neat trick that’s possible because the x64 instructions
are variable length:
| |
See what I did there? By flipping one byte (02 to 03) I changed the semantics of
pop r15 into pop rdi - isn’t that a neat trick?
Anyway, we now have all the parts we need, so here’s what we are going to do:
- Leak the libc and the stack addresses (43rd and 45th args).
- Compute the libc base, then the address of
system()and the “/bin/sh” string. Compute the address ofmain()return address. - Place the address of the
pop rdi; retngadget on the stack, in place of the original return address. Place the pointer to “/bin/sh” next (retaddr+8), and the address ofsystem()just after that (retaddr+16). - Exit main to trigger the ROP chain. Read the flag :-)
The exploit script
| |
References
[1] https://cs155.stanford.edu/papers/formatstring-1.2.pdf
[2] https://www.redhat.com/en/blog/hardening-elf-binaries-using-relocation-read-only-relro
[3] https://www.sans.org/blog/stack-canaries-gingerly-sidestepping-the-cage
[4] https://en.wikipedia.org/wiki/W%5EX
[5] https://en.wikipedia.org/wiki/Address_space_layout_randomization
[6] https://en.wikipedia.org/wiki/Return-to-libc_attack
[7] https://en.wikipedia.org/wiki/Return-oriented_programming
[8] https://www.ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/linux-x64-calling-convention-stack-frame
[9] https://docs.pwntools.com/en/dev/fmtstr.html