Espionage CTF Pwn challenge writeups Link to this heading

This is a writeup for the pwn challenges in the Espionage CTF 2024. This is also the first time I’m doing an in-person CTF event this year, and first win in a CTF event ever!

I played as a member of UofTCTF, a CTF team from the University of Toronto.

Members:

  • drec (that’s me) - pwn; uses ntr for “nice try”, anal for “analyze”; writes “love letters”
  • __fastcall - rev guy; “I am twelve” but doesn’t get banned on Twitch; does it for her
  • Tyler_ - forensics, OSINT; sleeps on time; did gym and ctf at the same time, still first blooded a challenge
  • SteakEnthusiast - web god; speedruns with large language models

Meltdown (15 points) Link to this heading

My teammate actually solved this, so this is my upsolve of the Meltdown challenge.

Here is the code for the challenge.

c
 1#include <stdio.h>
 2#include <stdlib.h>
 3int BUFFER_SIZE = 256;
 4
 5int main(){
 6  // char flag[BUFFER_SIZE];
 7  // FILE *flagfp = fopen("./flag.txt", "r");
 8  // fgets(flag, BUFFER_SIZE, flagfp);
 9  
10    printf("Welcome to the reactor's admin interface!\n");
11    int done = 0;
12    int fTemp = 70;
13    char input[BUFFER_SIZE];
14    
15    while(done == 0){
16        int choice;
17        int cTemp;
18        printf("What temperature would you like to set the reactor to? (Celsius)\n");
19        fflush(stdout);
20        
21        if (fgets(input, BUFFER_SIZE, stdin) == NULL) {
22            perror("Error reading input");
23            continue;
24        }
25        if (sscanf(input, "%d", &cTemp) != 1) {
26            printf("Invalid input. Please enter a number.\n\n");
27            continue;
28        }
29
30        if(cTemp < 500){
31            //whose idea was it to take celsius input and then convert to fahrenheit??????????
32            fTemp = cTemp * 2 + 32;
33            if(fTemp > 2000000000){
34                printf("temperature is %d degress F\n\n", fTemp);
35                printf("AAAAAAAAAAAAAAAAAAAAH TOO HOT!!!!!!\n");
36                // puts(flag);
37                done = 1;
38            }else{
39                printf("temperature is %d degress F\n\n", fTemp);
40            }
41                
42            }else{
43                printf("Woah! Too hot, could not change\n\n");
44            }
45    }
46    
47    return 0;
48}

You may have noticed that I have commented out the flag-related stuff, but don’t worry! we’ll just need to use the output to tell if we were successful in triggering the vulnerability.

In this challenge, there exists an integer underflow vulnerability, concerning the variable fTemp. The program asks for a number, and parses it as a signed integer. This is stored in the variable cTemp. Then, fTemp is set to cTemp * 2 + 32.

If cTemp is negative, then fTemp will be negative, right? Well, not quite. If we can make cTemp negative enough, then cTemp * 2 will underflow, and the result will be positive.

How can fTemp be positive? Link to this heading

If we have a signed integer, then the most significant bit is the sign bit. When we multiply by 2, depending on the compiler’s choice of implementation, the most significant bit may be shifted out. If the bit next to the original sign bit is 0, then the result will be positive, because it gets shifted to the left, where the original sign bit was. After that, adding 32 should not change the sign bit, which is easy to make sure of.

Here is an example code to see what I mean here:

c
 1#include <stdio.h>
 2
 3int main() {
 4	char a = -65;
 5    // This prints 10111111
 6	printf("%08hhb\n", a);
 7	a *= 2;
 8    // This prints 01111110, so everything gets shifted to the left
 9    // and the sign bit is replaced with a 0
10	printf("%08hhb\n", a);
11}

How did I find an input that will trigger the vulnerability? Link to this heading

We can use a tool called angr to find an input that triggers the vulnerability.

angr is a symbolic execution engine, which means that it will run the program, but instead of using concrete (real) values, it will use symbolic values (like variables in your algebra class).

When angr’s simulation manager reaches a conditional branch, it will try to go down both paths, and track down the constraints that will make the program go down that path.

In this case, we want to find an input that will make the program go down the path where fTemp is positive. In that path, the program will print AAAAAAAAAAAAAAAAAAAAH TOO HOT!!!!!!, so we can tell angr to find an input that will make the program print that.

I also noted that if the word input is printed, then the program will not actually print AAAAAAAAAAAAAAAAAAAAH TOO HOT!!!!!!, so we can tell angr to avoid any states where the word input is printed.

py
 1import angr
 2import sys
 3
 4proj = angr.Project('./meltdown')
 5
 6simgr = proj.factory.simgr()
 7
 8def target_state(state):
 9    return b'TOO HOT' in state.posix.dumps(sys.stdout.fileno())
10
11def avoid_state(state):
12    return b'input' in state.posix.dumps(sys.stdout.fileno())
13
14simgr.explore(find=target_state, avoid=avoid_state)
15
16if simgr.found:
17    print(simgr.found[0].posix.dumps(sys.stdin.fileno()))
18else:
19    print('Not found')

Running the script, and we get the input we need, and then some.

$ python angy.py
b'-1094000000\xda\xba\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

Notice that there are a lot of null bytes in the input. This is because fgets has a huge buffer size, but we don’t need to fill the whole buffer. angr just filled the rest of the buffer with null bytes.

Also, the \xda\xba is actually not parsed as part of the integer, so we can ignore it as well. This is probably a result of previous work done by angr, and since the program doesn’t read it, it left the bytes there. Note that sscanf will only read the numeric part of the input (for %d), and it stops reading for %d when it encounters a non-numeric character.

To conclude, the input we really need is -1094000000.

$ ./meltdown
Welcome to the reactor's admin interface!
What temperature would you like to set the reactor to? (Celsius)
-1094000000
temperature is 2106967328 degress F

AAAAAAAAAAAAAAAAAAAAH TOO HOT!!!!!!

Overflow depth (15 points) Link to this heading

This challenge is a buffer overflow challenge, or at least it seems like it.

We were given a file that is a 32-bit ELF executable. Take note that it is also a PIE executable, so the addresses will be randomized.

The vulnerability is in the handle_communication function. It does a call to gets, which is a function that reads a line from stdin, and stores it in the buffer that is passed as an argument.

The buffer is allocated on the stack, and it is 0x48 bytes long. Writing 0x8 bytes after the buffer will overwrite the entire return address of the function.

gef➤  disas handle_communication
Dump of assembler code for function handle_communication:
   0x00001303 <+0>:     push   ebp
   0x00001304 <+1>:     mov    ebp,esp
   0x00001306 <+3>:     push   ebx
   0x00001307 <+4>:     sub    esp,0x44
   0x0000130a <+7>:     call   0x10b0 <__x86.get_pc_thunk.bx>
   0x0000130f <+12>:    add    ebx,0x2ce5
   0x00001315 <+18>:    sub    esp,0xc
   0x00001318 <+21>:    lea    eax,[ebx-0x1fb8]
   0x0000131e <+27>:    push   eax
   0x0000131f <+28>:    call   0x1040 <printf@plt>
   0x00001324 <+33>:    add    esp,0x10
   0x00001327 <+36>:    sub    esp,0xc
   0x0000132a <+39>:    lea    eax,[ebp-0x48]
   0x0000132d <+42>:    push   eax
   0x0000132e <+43>:    call   0x1050 <gets@plt>
   0x00001333 <+48>:    add    esp,0x10
   0x00001336 <+51>:    sub    esp,0xc
   0x00001339 <+54>:    lea    eax,[ebx-0x1f98]
   0x0000133f <+60>:    push   eax
   0x00001340 <+61>:    call   0x1060 <puts@plt>
   0x00001345 <+66>:    add    esp,0x10
   0x00001348 <+69>:    nop
   0x00001349 <+70>:    mov    ebx,DWORD PTR [ebp-0x4]
   0x0000134c <+73>:    leave
   0x0000134d <+74>:    ret
End of assembler dump.

How did we know the details of the vulnerability? Link to this heading

First, gets is a function that is known to be vulnerable. The dynamic linker will warn us about this should we compile a program that uses gets.

Second before the call to gets, the address of the buffer is loaded into eax. This is the address of the buffer that gets will write to. In this case, it is ebp-0x48. This means that the buffer is 0x48 bytes away from the saved base pointer (ebp). Writing 4 bytes after the buffer will overwrite the saved base pointer, then another 4 bytes will overwrite the return address.

Here’s the layout of the stack, from the buffer, to the return address:

[ 0x48 bytes buffer ] [ 0x4 bytes saved base pointer ] [ 0x4 bytes return address ]

How can we exploit the vulnerability? Link to this heading

In this case, you can write 0x4c bytes first, then overwrite the return address to the address of secret_spy_function, which prints the flag.

There is a problem, though. The executable is PIE, so the address of secret_spy_function is randomized, along with all the other addresses of the function. You can combat this in two ways:

  1. Disable ASLR, so that the addresses are not randomized. You will still need to know the base address of the executable, and calculate the address of secret_spy_function. The good thing is that the address of the target function will be the same every time you run the program.
  2. Only overwrite the necessary bytes to overwrite. Usually the return address is 4 bytes, but because the offsets are next to each other, you can try to just overwrite the 2 least significant bytes of the return address. You can figure out why we need two instead of one by looking at the disassembly of the function (check the offsets of the instructions).

How did I get the flag? Link to this heading

Another way of solving is to not overwrite the return address at all. Instead, find a way to run the secret_spy_function on its own, not caring about the rest of the program.

By way of GDB Link to this heading

In GDB, you can set registers to whatever you want, during runtime, at any point of execution. This means that you can control EIP to whatever address, and GDB will obey. EIP is the instruction pointer, which is the register that stores the address of the next instruction to be executed.

This means that you can do the following in GDB:

start
print secret_spy_function
set $eip = fn_address_here
continue

The first command starts at a reasonable entry point of the program. The second command prints the address of the target function. The third command sets the instruction pointer to the address of the target function; just replace fn_address_here with the address of the target function. The fourth command continues execution, and the target function will be executed.

If you do it this way, you will get a SIGSEGV (a segmentation fault). This is fine. What matters is the output of the program, which contains the flag.

By way of angr Link to this heading

angr can also be used to solve this challenge. This time, we can set the starting address to the address of the target function, and then run the program from there.

py
 1import angr
 2import sys
 3
 4proj = angr.Project('./ctf_challenge')
 5
 6state = proj.factory.call_state(proj.loader.find_symbol('secret_spy_function').rebased_addr)
 7
 8simgr = proj.factory.simulation_manager(state)
 9
10simgr.run()
11
12print(simgr.deadended[0].posix.dumps(sys.stdout.fileno()))

When you run the script, you will get the flag.

$ python angy.py
WARNING  | 2024-01-28 20:15:04,660 | angr.calling_conventions | Guessing call prototype. Please specify prototype.
WARNING  | 2024-01-28 20:15:04,671 | angr.storage.memory_mixins.default_filler_mixin | The program is accessing register with an unspecified value. This could indicate unwanted behavior.
WARNING  | 2024-01-28 20:15:04,672 | angr.storage.memory_mixins.default_filler_mixin | angr will cope with this by generating an unconstrained symbolic variable and continuing. You can resolve this by:
WARNING  | 2024-01-28 20:15:04,672 | angr.storage.memory_mixins.default_filler_mixin | 1) setting a value to the initial state
WARNING  | 2024-01-28 20:15:04,672 | angr.storage.memory_mixins.default_filler_mixin | 2) adding the state option ZERO_FILL_UNCONSTRAINED_{MEMORY,REGISTERS}, to make unknown regions hold null
WARNING  | 2024-01-28 20:15:04,672 | angr.storage.memory_mixins.default_filler_mixin | 3) adding the state option SYMBOL_FILL_UNCONSTRAINED_{MEMORY,REGISTERS}, to suppress these messages.
WARNING  | 2024-01-28 20:15:04,672 | angr.storage.memory_mixins.default_filler_mixin | Filling register ebp with 4 unconstrained bytes referenced from 0x4011f4 (secret_spy_function+0x0 in ctf_challenge (0x11f4))
WARNING  | 2024-01-28 20:15:04,673 | angr.storage.memory_mixins.default_filler_mixin | Filling register ebx with 4 unconstrained bytes referenced from 0x4011f7 (secret_spy_function+0x3 in ctf_challenge (0x11f7))
WARNING  | 2024-01-28 20:15:05,277 | angr.storage.memory_mixins.default_filler_mixin | Filling memory at 0x7ffefff0 with 4 unconstrained bytes referenced from 0x556fb0 (_IO_printf+0x0 in libc.so.6 (0x56fb0))
WARNING  | 2024-01-28 20:15:05,278 | angr.storage.memory_mixins.default_filler_mixin | Filling memory at 0x7fff0000 with 106 unconstrained bytes referenced from 0x556fb0 (_IO_printf+0x0 in libc.so.6 (0x56fb0))
b'Access Granted! The secret code is: EspionageCTF{0v3rfl0w_D3ptH_9xi7Q2v}\n'

Note: The only reason that the GDB and angr solutions were possible is because the program contains the secret_spy_function function, which prints the flag. If the function drops a shell instead, and the executable is hosted on a remote server, then you will need to find a way to do the buffer overflow, in order to trigger the secret_spy_function, and get the flag using the dropped shell.

Paddingtly Insane (97 points) Link to this heading

Now this one is a bit more interesting. It has something to do with cryptography, and how we pad encrypted messages.

I actually learned about how AES-CBC works from work last week, so I was able to solve this challenge pretty quickly, and get first blood!

What is AES-CBC, and block ciphers? Link to this heading

AES, or Advanced Encryption Standard, is a symmetric-key block cipher. CBC, or Cipher Block Chaining, is a mode of operation for block ciphers like AES.

If you didn’t know, AES-CBC is used in TLS, which is the protocol that secures your connection to websites that use HTTPS.

How does it work? Well, first, we need to know what a block cipher is.

A block cipher is a cipher that encrypts a fixed-size block of data. In this case, 128-bit AES encrypts 16 bytes of data at a time.

CBC is a mode of operation for block ciphers. It tells us how to encrypt the next block of data, given the previous block of data.

AES-CBC tells us to XOR the previous block of data with the current block of data, then encrypts the result using AES.

Now why do we need to know this? PKCS#7 is why. Link to this heading

PKCS#7 is a way to pad messages to a multiple of the block size.

Imagine that we have a message that is 13 bytes long, and we want to encrypt it using AES-CBC. We can’t encrypt it directly, because the message is not a multiple of 16 bytes. We need to pad it first.

We can pad it with 3 null bytes, but how are we going to know how many null bytes are there used for the padding? Also, what if the message already ends with null bytes? We’ll have no way of knowing how many null bytes are used for padding.

This is where PKCS#7 comes in. It tells us how many bytes to use for padding, and it also tells us what the padding byte values are.

It is actually very simple. The number of padding bytes is equal to the byte value of the padding bytes. For example, if we need to pad 3 bytes, then we pad our message with 3 bytes of the value 3.

[ 13 bytes message ] [0x03 0x03 0x03]

Now we know that there are 3 bytes of padding, and we know what the padding bytes are. What if the message is already a multiple of the block size? Then we need to add a whole block of padding bytes.

[ 16 bytes message ] [0x10 bytes of 0x10]

Now, if we want to decrypt the message, we can just look at the last byte of the decrypted message, and see how many bytes we need to remove.

Exploit idea Link to this heading

The program is written in C#.

cs
 1using System.Runtime.InteropServices;
 2using System.Security.Cryptography;
 3using System.Text;
 4
 5class MainReturnValTest
 6{
 7    static String Authenticate(string id)
 8    {
 9        String val = String.Empty; 
10        Aes algo = Aes.Create();
11        algo.KeySize = 128;
12        algo.Key = new byte[] { 0x23, 0xB6, 0x49, 0xEA, 0x0B, 0x06, 0xAD, 0xBF, 0x61, 0x0f, 0x5D, 0xF3, 0xF0, 0x3D, 0xAF, 0xC3, 0xE2, 0x7A, 0xA4, 0xB7, 0x02, 0x9d, 0xC8, 0x43, 0x93, 0xE9, 0x42, 0xE7, 0xE0, 0xCB, 0xDB, 0x3A };
13        algo.IV = new byte[] { 0xC4, 0x90, 0xFD, 0xC4, 0x93, 0xED, 0x36, 0xA4, 0x8A, 0xCD, 0xA2, 0xB1, 0xAB, 0x80, 0x18, 0x6A };
14
15        //Begin Authentication
16        try
17        {
18            byte[] cipherText = ConvertToBytes(id);
19
20            algo.DecryptCbc(cipherText, algo.IV, System.Security.Cryptography.PaddingMode.PKCS7);
21            val = "authenticated";
22            return val;
23        }
24        catch (System.Security.Cryptography.CryptographicException e)
25        {
26            Console.WriteLine(e.Message);
27        }
28        Console.WriteLine(val);
29        return val;
30    }
31
32
33    static byte[] ConvertToBytes(String payload)
34    {
35        byte[] bytes = payload.Select(c => (byte)c).ToArray();
36
37        return bytes;
38    }
39
40    static void Main(string[] args)
41    {
42        Console.WriteLine("Welcome to the Flag Storage Facility. Please enter your 32-character, encrypted, authentication string to access the flag:");
43        String result = Authenticate(args[0]);
44
45        if (!String.IsNullOrEmpty(result)){
46            Console.WriteLine("Welcome to the Flag Storage Facility!");
47            Console.WriteLine(args[0]);
48            Console.WriteLine("EspionageCTF{NOT_THE_FLAG}");
49        }
50    }
51}

The program reads the first argument, and is treated as the encrypted message. It then decrypts the message, and prints the decrypted message.

The key and IV (initial vector) are hardcoded, so we can just use the same key and IV to decrypt the message ourselves (remember, AES is a symmetric-key cipher, so the same key can be used for decryption).

If the decryption is successful, then the function Authenticate will return the string authenticated, and the program will print the flag, because result is not empty. If the decryption is unsuccessful, then the function will return an empty string, and the program will not print the flag. Instead, it will error out.

Now give yourself some time to think about how we can exploit this.

… You done? Good.

If you haven’t noticed, the program prints the decrypted message, regardless of whether the authentication is successful or not. This means that we can just give it any encrypted message, and it will print the decrypted message. That is enough to get the flag, because the flag has nothing to do with the message.

Exploit the “lame” way - encrypt your own message Link to this heading

The intended solution - the “cool” way - is to play around the padding bytes, and somehow brute force the last byte of the message, and see if the padding is valid.

I’m not going to explain this, because I don’t know how to do it myself. Contact the challenge author if you want to know how to do this.

My solution - the “lame” way - is to exploit the fact that AES is a symmetric-key cipher, and the key and IV were given to us. This means that we can encrypt any message we want, and the program can decrypt it for us. After all, our only goal is to not cause a CryptographicException.

And with that being said, I implemented the encryption function in C#, inside the MainReturnValTest class of the challenge source code.

cs
 1static String encrypt(string id) {
 2    Aes algo = Aes.Create();
 3    algo.KeySize = 128;
 4    algo.Key = new byte[] { 0x23, 0xB6, 0x49, 0xEA, 0x0B, 0x06, 0xAD, 0xBF, 0x61, 0x0f, 0x5D, 0xF3, 0xF0, 0x3D, 0xAF, 0xC3, 0xE2, 0x7A, 0xA4, 0xB7, 0x02, 0x9d, 0xC8, 0x43, 0x93, 0xE9, 0x42, 0xE7, 0xE0, 0xCB, 0xDB, 0x3A };
 5    algo.IV = new byte[] { 0xC4, 0x90, 0xFD, 0xC4, 0x93, 0xED, 0x36, 0xA4, 0x8A, 0xCD, 0xA2, 0xB1, 0xAB, 0x80, 0x18, 0x6A };
 6
 7    byte[] plainText = ConvertToBytes(id);
 8    byte[] cipherText = algo.EncryptCbc(plainText, algo.IV, System.Security.Cryptography.PaddingMode.PKCS7);
 9
10    return ConvertToString(cipherText);
11}
12
13static String ConvertToString(byte[] bytes)
14{
15    String payload = String.Empty;
16    foreach (byte b in bytes)
17    {
18        payload += (char)b;
19    }
20    return payload;
21}

The encrypt function takes a string, and returns the encrypted string. The ConvertToString function converts a byte array to an ANSI string. I did it that way so I have less fear of encoding issues.

Now I modified the Main function to encrypt almost any message I want stored in a variable, and use that as the argument to Authenticate.

cs
1String wee = encrypt("admin"); // this fails btw lol
2
3String result = Authenticate(wee);

As you can see in the comment above, the encryption of the string admin fails to authenticate. This is because the encrypted message ends with a newline character, and the program hosted on the server does not like that. The server reads till the newline, but doesn’t include the newline in the message, so the padding is invalid.

To fix this, just don’t use a message that encrypts to a data that doesn’t contain a newline.

cs
 1static void Main(string[] args)
 2{
 3    String wee = encrypt("0");
 4    String result = Authenticate(wee);
 5
 6    if (String.IsNullOrEmpty(result)) {
 7        return;
 8    }
 9
10    if (wee.Contains("\n")) {
11        Console.WriteLine("encrypted message contains newline");
12        return;
13    }
14
15    using TcpClient client = new TcpClient("paddingtlyinsane.ctf.issessions.ca", 7777);
16    using NetworkStream stream = client.GetStream();
17    using StreamReader reader = new StreamReader(stream);
18    using StreamWriter writer = new StreamWriter(stream);
19
20    String? line = reader.ReadLine(); // welcome and input prompt
21
22    Console.WriteLine("Sending payload");
23    writer.Write(wee);
24    writer.Flush();
25    Console.WriteLine("Payload sent");
26
27    reader.ReadLine(); // the welcome message
28    reader.ReadLine(); // your input
29    String? flag = reader.ReadLine();
30    Console.WriteLine(flag);
31}

If we run the program, we get the flag.

$ dotnet run --project .
Sending payload
Payload sent
EspionageCTF{Encrypt3d_N0t_auth3nticat3d}

Conclusion Link to this heading

I hope you enjoyed reading this writeup, and I hope you learned something from it. Shoutouts to ISSessions for hosting this CTF, and shoutouts to the challenge authors for making these challenges.

And with that, the pwn is done. Look forward to a writeup for ScrambledSquares, but I’ll have to do it later, when my 8-year-old brother can understand it; that is, when he nods at every sentence I explain to him.

__fastcall will be writing the writeup for ScrambledSquares as well, and I would suggest you look forward to it if you have experience in reverse engineering.