Summary:
A buffer overflow that enables
1) reading the first line of any file,
2) inspecting the stack canary of main, and
3) controlling the stack after return from main.
Read /proc/self/maps to get the executable's location in memory,
leak main's stack canary,
smash the stack of main (repairing the stack canary)
to return to printf and leak the address of a libc function,
return back into main,
smash the stack once more with a ROP chain constructed from libc code.
I worked on this challenge as part of a team.
(Let me know if you were on the team and want your name mentioned here.)
This was my first time completing a real ROP challenge with full ASLR;
the only earlier time I had done it, I used a one-gadget
and didn't need a full ROP chain.
Solution files:
full_troll.zip.
We get a single ELF binary, which is the code of a process
running on a remote server that we have to exploit.
Reversing it with Radare2, we get the following approximate pseudocode:
The program asks for a password.
If the password is correct,
it then opens the file named by filename ("secret.txt" by default).
If the file can't be opened, it prints the name of the file;
otherwise, it prints the first line of the file.
It loops until the first byte of filename is 0,
at which point main returns.
The first obstacle is the password.
check_password asserts that the password
must be at least 23 bytes long.
It checks that the XOR of all pairs of adjacent bytes
have certain values, and finally that the 23rd byte
has a specific value.
We know the final byte, s[22] == 0x58 ('X').
We also know that s[21] ^ s[22] == s[21] ^ 0x58 == 0x61,
which means that s[21] == 0x61 ^ 0x58 == 0x39 ('9').
Continuing in the fashion to the beginning, we recover the full password
VibEv7xCXyK8AjPPRjwtp9X.
Any input we provide to the program must begin with this prefix.
Sending the password alone causes the program
to print the first line of secret.txt,
a useless troll message.
The read_line(stdin, password) call
has no length check and therefore may overflow the
32-byte password buffer into the 40-byte filename buffer.
The password is 23 bytes long, so we need 9 additional bytes of padding
to fill the password buffer.
Anything after that starts to overflow into filename.
If the file does not exist, or if it does not pass check_filename,
we get the filename string printed out
in an error message.
If the file exists and has a suitable name, we get its first line.
flag.txt contains the same troll message as secret.txt.
What file is there whose first line can help us?
The first line of /proc/self/maps gives the address at which
the program is loaded in memory,
enabling us to bypass ASLR for the text segment.
Here, the executable's base address is 0x000055597b7e8000.
It changes every time you connect.
We also overflow the buffer even further
to overwrite the saved return address in the stack frame of main.
There's a problem, though: before the stack frame,
there's an 8-byte stack canary.
The memory layout looks like this:
main checks that the canary has the expected value before returning.
If it does not, the program aborts with Stack smashing detected
and our overwritten rip is never even jumped to.
We can use the fact that the program prints the name of the file
it tried to open to leak the bytes of the canary.
We need to send the password, then 9 bytes of padding to fill the password buffer,
then 40 bytes of padding to fill the filename buffer.
The first byte of the canary is always 0x00,
so we must then send 1 more byte to join the filename string to the bytes of the canary.
The printf call in report_check_result
will print up to 48 bytes of the filename, which is exactly as much as we need.
I added highlighting to illustrate
what parts of the input and output correspond to
what parts of the stack.
During this run, the value of the canary was 0094c7801062afe0.
Now we can safely smash the stack in main,
by first leaking the canary, then being careful to restore it
whenever we write over it.
This process of reading the canary will fail whenever the canary contains a 0x00 byte
in the middle, because that will terminate the string as seen by printf.
You could write a loop to continue reading later parts of the canary in this case,
or you could just ignore the problem and repeatedly run your commands
until you get a good canary (which is what we did).
The right thing to do at this point at this point
would be to leak a libc address, in order to gain access to more ROP gadgets.
But we did not immediately realize that we could use the printf
in report_check_result to do that.
Instead, we tried for a while to build an exploit using only the gadgets
available in the binary itself.
There weren't many of these; notably we lacked even a syscall gadget.
We weren't initially trying to open a shell—we
suspected (wrongly, it turns out) that secret.txt was longer than one line
and that the flag would be somewhere later in the file.
We used ROPgadget and
Ropper
to get a list of gadgets in the file.
There were a pop rdi; ret;
and a pop rsi; pop r15; ret;
that enabled us to call fopen on the strings "secret.txt"
and "rb"
to get a file handle in rax.
(rdi is the first function argument and
rsi is the second; we can put garbage in r15.)
But we couldn't find a way to transfer that file handle from rax
to rdi to pass it on to further functions like read_line.
We spent some time trying to use an awkward
mov rdi, rax; call strcpy; nop; leave; ret gadget,
hoping that we could get strcpy to work as a no-op,
but we couldn't stop the leave instruction from
trashing the state of the stack pointers.
Eventually we realized that we could get a libc address.
The printf call in report_check_result
prints up to 48 bytes starting at a caller-provided address.
We'll use the address of getenv.
We found the offset of getenv's relocation entry
using Radare2:
We also need the offset of report_check_result,
which is 0x00000ded.
At this point we had to start writing an exploit script instead of just running nc,
because there are multiple dependent steps.
To get the address of getenv,
Read /proc/self/maps to get the executable base address.
Leak the stack canary of main.
Overwrite the saved rip of main,
taking care to restore the canary,
then stage a ROP chain that
Stores the value 3 in rdi (the first argument to report_check_result).
Stores the address of getenv's relocation entry
(which is the executable's base address plus 0x00201f78) in rsi
(the second argument to report_check_result).
Jumps to report_check_result, causing it to print the getenv relocation entry.
Send a filename whose first byte is 0. This is the only way to cause main to return and our ROP chain to start executing.
Read a line from the server, which will be the "Unable to open XXXXXX file!" error string, where XXXXXX is the bytes of the address of getenv.
If we know the precise version of libc that the server is running,
we can combine that with the address of getenv
to get the libc base address.
From reading the first line /etc/issue, we get that the server is running Ubuntu 18.04.3.
The Ubuntu packages site
says that this version of Ubuntu uses libc6 version 2.27-3ubuntu1.
We can download the deb file
and unpack it using dpkg-deb.
readelf -s tells us the offset of getenv, 0x426e0.
Subtracting that from the leaked address of getenv
yields the libc base address.
Here's what the code to do all this looks like.
stdin and stdout
are the input and output stream of the executable
(either a local process's stdin and stdout, or a socket to a remote server).
Notice when we read the address of getenv we read only 6 bytes;
that's because the pointer is something like
0x00007fXXXXXXXXXX with the first two bytes zero,
and in little-endian byte order looks like
XXXXXXXXXX7f0000.
We have a small problem here, which is that we need to continue interacting
with the program after learning the libc address,
but we have just caused main to return.
To regain control, we stage the address of main itself
after the address of report_check_result,
to cause the code to jump back into main
after we get the printf results we want.
From there, we can exploit the program again,
using a second ROP chain.
My first thought at this point was to try a one-gadget,
because that worked for me in a previous CTF.
A one-gadget is a single address you can jump to in libc
that immediately execs /bin/sh.
The one_gadget program found three potential addresses,
subject to certain constraints:
While working on the exploit we were of course testing locally using the full_troll executable.
On a Debian 10 system, which has a different libc than Ubuntu 18.04.3,
there are also three one-gadgets found,
and at least one of them worked, after making the necessary address adjustments.
But none of them worked against the challenge server, for whatever reason.
So we then resorted to using a full ROP chain found by Ropper:
Ropper outputs some Python code that you can just paste into your program
(you only have to provide the libc base address).
Unfortunately, it still didn't work.
We could get the remote server to run a dummy puts("secret.txt") payload,
but not the one that runs a shell.
Testing inside an Ubuntu 18.04.3 VM, we found that it was crashing somewhere
inside the printf call inside report_check_result
inside the second call to main.
The exploit was being staged successfully but the program was crashing before main
could return the second time.
We compared the state of registers at the point of calling printf
on the local machine, where the exploit worked, and in the VM, where it didn't.
One of the differences was the rcx == 0 on the local machine,
and rcx == 1 in the VM.
So we threw in a dec ecx; ret; gadget
before the return to main,
and finally that worked.
Sending an ls after spawning the shell results in a directory listing.
(Forgive the repr output formatting,
that's just an artifact of how we were reading the server results after sending the exploit.)
So the flag is in a file called _r3al_fl4g_eTF8eO9k4LkAOqrl4_r341_fla6__.txt,
not in secret.txt like we thought.
Running the exploit again with cat _r3al_fl4g_eTF8eO9k4LkAOqrl4_r341_fla6__.txt
in place of ls yields the flag
CTF-BR{Fiiine...Im_not_ashamed_to_say_that_the_expected_solution_was_reading_/dev/fd/../maps_How_did_y0u_s0lve_1t?}.
I am actually not sure why forcing rcx == 0 had an effect.
I thought that the number of arguments that printf expects
was entirely controlled by the format string—I don't see why it
should even have been looking at that function argument.
Looking back, perhaps the one-gadget at offset 0x4f2c5,
the one with the rcx == NULL constraint,
would have worked as well, after fixing rcx.
That's the one-gadget that worked for archiveer.
Here's the full exploit code, debugging warts and all:
According to mephi42's comment,
we could probably have used /proc/self/syscall to get a libc address directly
and avoid the two-stage ROP exploit.
By the way, the "sacred" file contained the same troll message as
secret.txt and flag.txt.
Maybe it was targeted at people trying a dictionary attack over filenames.
The contents of setup.sh were just
Random Crypto
At some point Sophia has realized that a few messages were being intercepted and it has decided to send encrypted messages at random and the encryption parameters were sent as a separate message to different people on the same team, so that the chances of both messages being intercepted were much smaller, almost negligible. We have been able to intercept the main message with great effort and we believe that, like Sophia code, there is a flaw in its encryption method. Can you help us find the information we are looking for? Ah... Rebellious Fingers has done a great job of stealing the encryption method binary and sending it to 2019 along with the broadcast!
Summary:
Invertible, randomized transformation of input,
initially seeded by srand(time(NULL)).
Implement the inverse transform
and brute-force the seed timestamp.
Update : I was wrong about the nature of the challenge,
but my slightly wrong approach led to the correct solution nevertheless.
See below..
I did this challenge on my own and didn't submit the flag.
I finished it a couple of hours after the contest had ended, anyway.
Solution files:
random_crypto.zip.
We get an ELF binary
and a text file full of
14444 largish (~2000-bit) decimal integers,
positive and negative.
Reversing the binary reveals that it reads a file called "flag"
into an array of bigints and
seeds a random number generator with the current time.
The random number generator is used to determine a number of passes to make over the data.
In each pass, elements are processed in twos:
the pair of adjacent numbers a and b are replaced by
a+b and a−b.
Additionally, in each pass a randomly chosen odd-indexed element has its least significant bit flipped.
Finally, the processed array of bigints is output in reverse order.
Here is pseudocode:
All of these operations—the xors, the sum and difference—are invertible.
The game plan is to write a program to invert the encryption operation
given a seed, then brute-force the seed.
My first thought for writing a program that will do bigint math and
run reasonably fast was Go with math/big.
This program will turn out to require a few changes around the random number generator before it actually works.
The decryption program needs to reproduce the sequence of rand() values
that was used during the encryption.
(Or so I thought—more details below.)
I thought about reimplementing the Glibc random number generator,
but it's more complicated
than just a simple linear congruential generator.
Instead, I wrote a helper program in C
that just outputs a sequence of random numbers,
given a seed on the command line,
and read its output from the Go program.
I tested the program by using the binary to generate my own ciphertext
with a known seed, then decrypt it.
Now I needed to guess the challenge's original seed timestamp.
I anchored the search around the timestamps in the challenge tarball.
The xargs trick is a nice way to run
several copies of a program in parallel
and make effective use of all your CPU cores.
This search was initially running too slowly, so I had patched decrypt.go
to only decrypt the first 8 bytes.
As you can see, most guesses do not decrypt to a valid byte string,
and even most of those that do,
are not anything meaningful.
I let the search run and then visually inspected the
non-failure cases in solve.log.
The "\x89PNG\r\n\x1a\n" match immediately jumps out,
because that's a
PNG header.
I patched the decrypt program again to process all the bytes
and write the output directly to a file.
The result resembles a PNG file:
But no graphics software will open it,
because it is somehow corrupted.
Knowing something about the PNG format,
I wrote a hacky script
to extract the pixel data
(uncompressed contents of IDAT chunks)
to have a look at it.
The output of the program shows that at least one
problem with the file is that the zlib stream
representing the compressed pixel data
has an incorrect checksum,
indicating some kind of corruption:
The problem was that my decryption algorithm was not completely correct,
but I did not realize that right away.
I thought perhaps the challenge authors had deliberately corrupted the file
as a final step to overcome.
A nice trick for inspecting uncompressed graphics
is to save them with a ".data" filename extension
and open them in the GIMP.
You'll get a dialog that allows you to explore different settings
of height/width and pixel format.
There is clearly some data there, but it doesn't look like much.
The rightward drift of the repeating patterns is caused by
the PNG filter
which adds an extra byte at the beginning of each row.
Many of the rows are filtered using the Sub filter,
which means most pixels are stored as the difference from the previous pixel.
A solid white row, for example, would be stored as a single white pixel
followed by a row of transparent pixels
(you can see that happening in the upper left of the screenshot).
I wrote another hacky script to undo the filtering
and got some barely intelligible data.
It's still corrupted, but it clearly contains the flag—all flags
in this contest started with "CTF-BR".
I decided the problem must be in the decryption program.
Take a closer look at this fragment:
The calculation (a+b) + (a−b)
always results in an even number.
Therefore every pair in cfile should sum to an even number
except for one, the one that was randomly chosen to have a bit flipped.
I tested and there was indeed only one odd sum during each iteration—but
not where the rands array expected it.
I still am not sure what went wrong;
obviously a divergent implementation of rand could be the culprit,
but in that case even the iterationCount would have been incorrect
and produced nothing close to a working output file.
Anyway, I realized that I didn't actually need to know the rand()
sequence to know which bit to unflip,
I only needed to look for the pair with the odd sum, and flip that one:
With that change, I finally got a working image and the flag,
CTF-BR{p4Ri7Y_1S_s0_1mP074Nt_iN_In736ErS}.
I was surprised that I had gotten so close to a working output image
with a malfunction like that in the decryption program.
It may be because, at least half the time, the bit flip
has no effect on the output.
The division by 2 results in the same output,
even without taking the bit flip into account,
whenever the original sum a+b is even.
input
transformed with bit flip
inverted without bit flip
a
b
x = a+b
y = (a−b)⊕1
(x+y)/2
x−(x+y)/2
ok?
2
4
6
−1
2
4
✅
2
5
7
−4
1
6
❌
2
−4
−2
7
2
−4
✅
2
−5
−3
6
1
−4
❌
−2
4
2
−5
−2
4
✅
−2
5
3
−8
−3
6
❌
−2
┈4
−6
3
−2
−4
✅
−2
┈5
−7
2
−3
−4
❌
Update:
After reading
the organizers' writeup,
I see what my misunderstanding was and how I arrived at the solution despite it.
The complicated calc_iteration_count(r) function is just
optimized code for r % 4011.
My brute-force timestamp search didn't find the correct random seed,
it just happened to find a seed whose first random output had the correct
residue mod 4011.
That explains why (seemingly) the first random number was correct
but all the following ones were wrong.
Then, the fact that the brute-force search only looked at the first 8 bytes
meant that I was not looking at the majority of the file,
where the incorrectly placed bit flips would have had an effect.
This challenge didn't require guessing the seed.
Just keep iterating, flipping the odd pair each time,
until you get all valid byte values.