The challenge

For this challenge we were given a Python script (leaky_blinders.py) that would run when we connect to the server, allowing us to interact with it. Our task was to find a way to leak the flag that the script encrypted using AES with a random key.

Overall, it was not a hard challenge (in fact, it was probably the easiest on this particular CTF), but it was kind of annoying to solve. First, there are two possible ways to solve it, one being ugly, very inneficient and when you think about it, completely dumb (that’s was the solution I used, by the way) and the second is quick, simple and clever. I’m not sure which of these two was the intended solution (probably the second one, maybe?) and unfortunately I only found about the second way when I was revising the code for this writeup (which made me really annoyed). For the sake of completiness, I will explain both of these methods here.

The Code

The code was pretty straight forward. Upon execution it would get a random 32-bytes key using os.urandom which would be used to encrypt the flag that is then sent to us followed by a menu. The interesting part was how the encryption was performed:

 12 key = os.urandom(32)
 13
 14 def xor(a, b):
 15     return bytearray([a[i % len(a)] ^ b[i % len(b)] for i in range(max(len(a), len(b)))])
 16
 17 def encrypt(msg):
 18     aes = AES.new(key, AES.MODE_ECB)
 19     if len(msg) % 16 != 0:
 20         msg = pad(msg, 16)
 21     cipher = aes.encrypt(msg)
 22     cipher = xor(cipher, key)
 23     return cipher

As we can see, the text is encrypted using AES-128 in ECB mode and after that, the resulting ciphertext is XORed with the key. Similarly, the decryption process would first do a XOR of the key with the cipher text and then perform a regular decryption.

 25 def decrypt(cipher, k):
 26     aes = AES.new(k, AES.MODE_ECB)
 27     cipher = xor(cipher, k)
 28     msg = unpad(aes.decrypt(cipher), 16)
 29     return msg

On the menu that was presented to us, there were 3 options:

 39                 print("\n1- Encrypt")
 40                 print("2- Decrypt")
 41                 print("3- Leave")
 42                 c = input("> ")
 43
 44                 if c == '1':
 45                     msg = os.urandom(32)
 46                     cipher = encrypt(msg)
 47                     if all(a != b for a, b in zip(cipher, key)):
 48                         print(cipher.hex())
 49                     else:
 50                         print("Something seems leaked !")
 51
 52                 elif c == '2':
 53                     k = bytes.fromhex(input("\nKey : "))
 54                     cipher = bytes.fromhex(input("Ciphertext : "))
 55                     flag = decrypt(cipher, k)
 56                     if b"FwordCTF" in flag:
 57                         print(f"Well done ! Here is your flag : {FLAG}")
 58                     else:
 59                         sys.exit("Wrong key.")
 60
 61                 elif c == '3':
 62                     sys.exit("Goodbye :)")

1 - Encrypt would encrypt a random 32-bytes string obtained using os.urandom and do a byte-to-byte comparission of the ciphertext with the key, displaying an error message if any of the characters match, otherwise the ciphertext would be displayed.

2 - Decrypt would read a key and a ciphertext from the user and decrypt it using the previously discussed function. If the resulting plaintext contains the string “FwordCTF”, the flag would be displayed. (I should have taken more attention to this part)

3 - Leave would close the connection.

As you may have noticed, everything about this challenge, from the name to the weird choice of cryptographic function and the checks performed on the ciphertext, sugested that we would need to write some sort of magical script to feed crafted input to the server and analyze the output to leak the key or even the flag itself one bit at a time… and that was exactly what I tryed to do (silly me!).

The ugly solution (How I did it)

My fist aproach to solve this challenge was based on the checks peformed when we choose the first option. A random 32-bytes string is encrypted and then compared to the key, ensuring that no byte of the key was equal to the byte of the ciphertext at the same position. If we got the cipher text back when choosing this option, the only thing we know for sure is that it was not the key (surprise, surprise!). This doesn’t seem to help much, but we can actually find much about the key working on this assumption.

As the key has 32 bytes and each byte is a number from 0 to 255, that gives us 255^32 possibilities (which is a really huge number, good luck bruteforcing that…). Lucky for us, everytime we get a ciphertext back, we can remove the character at each position of the ciphertext from the list of possible characters at the same position of the key. For example, if the 5th byte of the cipher text is 0x41 (hex for the char ‘A’), we can be sure that the 5th character of the key is not 0x41. Can you see where I’m trying to get at with this?

Using this aproach, we can narrow down the possible values for each byte of the key by performing multiple requests for option 1, until there is only one character left for every position, this way obtaining the key! (It sounds smart now, but wait…)

Now that the idea is laid out let’s see my (crappy) code:

  1 #!/usr/bin/env perl
  2 
  3 use strict;
  4 use warnings;
  5 use IO::Socket::INET;
  6 
  7 my $sk = IO::Socket::INET->new(
  8     PeerAddr => '52.149.135.130',
  9     PeerPort => 4869,
 10     Proto    => 'tcp',
 11 ) || die "Can't connect";
 12 
 13 <$sk> for 1 .. 2;
 14 chomp(my $data = <$sk>);
 15 my $encflag = (split / /, $data)[-1];
 16 my %chars = map { $_ => 1 } 0 .. 255;
 17 my @invalid = map { { %chars } } 1 .. 32;
 18 my @found = ();
 19 my $count = 0;
 20 my $total = 0;
 21 while ($count < 32) {
 22     $total ++;
 23     print $sk "1\n";
 24     chomp($data = <$sk>);
 25     my $ans = (split / /, $data)[-1];
 26     next unless $ans && length($ans) == 64;
 27     #print "$ans\n";
 28     my @pos = map { hex } $ans =~ /[\d\w]{2}/g;
 29     for (my $i = 0; $i < @pos; $i ++) {
 30         delete($invalid[$i]->{$pos[$i]});
 31         my @keys = keys %{$invalid[$i]};
 32         if (@keys == 1 && !defined($found[$i]))
 33         {
 34           $found[$i] = shift @keys;
 35           $count ++;
 36         }
 37     }
 38 }
 39  
 40 print "$total ciphertexts analysed.\n";
 41 my $key = join "", map { sprintf "%02x", $_ } @found;
 42 print "Encrypted flag: $encflag\n";
 43 print "Key: $key\n";
 44 print $sk "2\n";
 45 print $sk $key, "\n";
 46 print $sk $encflag, "\n";
 47 while (1) {
 48     chomp(my $txt = <$sk>);
 49     if ($txt =~ /flag/i) {
 50         my $flag = (split /'/, $txt)[1];
 51         print "Flag: $flag\n";
 52         last;
 53     }
 54 }

The code presented above is the script I wrote to solve this challenge. As you can see, I start by opening a connection to the server, reading the initial text that is sent and extracting the encrypted flag. Next I create a hash that contains all 256 possible values for one byte that is then copied as a hashref to every position of an array of 32 items (each representing a position of the key). I also initialized some control variables to store the recovered characters and how many ciphertexts were processed.

The script then follows to a loop where it will repeatedly request the option 1 (Encrypt) and read the output (note that I’m not reading the menu explicitly because doing so would be about 5x slower), validating if it is a ciphertext with 64 bytes. Then, convert it to an array of integers using the Perl function hex() inside a map iterating over the array of matchs from a regex to split each hexadecimal byte.

After that, for each character at the position i of that array, we delete it from the hash of possible values for the character i of the key. If there is only on possibility left fot that position, we set it’s value on the array @found and increment $count to keep track of the number of bytes found, using it to control the while loop.

At the end, we print the information we got, like the encrypted flag and the recovered key. At this point, we already have the ciphertext and the key and we also know how to decrypt it, so we could do it ourselves, but as the server gives us the option to decrypt for us, let’s use it. The final while loop will handle the lines of the menu that would have been left on the buffer of the socket, until it finds a message that contains the flag, which is extracted and printed to the screen.

And that is it, we got the flag the dumb way. Now, let’s see a more sensible aproach…

The smart solution (How I should have done it)

Well, as you may have noticed, the menu that is presented to us gives a pretty interesting option of decrypting a ciphertext using a key, both provided by us and if the resulting plaintext contains the string “FwordCTF” in it, the flag (and not the plaintext!) is displayed to us. That means that instead of going through all that trouble of leaking the key, we could have got the flag using any key and (almost) any plaintext!!!

To do this, all we need is a suitable text that contains the mentioned piece os string. Let’s use, for example, the text “FwordCTF{by_the_order_of_the_leaky_fookin_blinders}” (just to keep the reference of this challenge). We could just encrypt this text using any key, the same way they did in the challenge and send the key and ciphertext to the server having choosed option 2 and the flag would be sent back… :)

Below is a snippet of the code to do this:

  1 from Crypto.Cipher import AES
  2 from Crypto.Util.Padding import pad, unpad
  3 import os, socket
  4
  5 key = os.urandom(32)
  6
  7 def xor(a, b):
  8     return bytearray([a[i % len(a)] ^ b[i % len(b)] for i in range(max(len(a), len(b)))])
  9
 10 def encrypt(msg):
 11     aes = AES.new(key, AES.MODE_ECB)
 12     if len(msg) % 16 != 0:
 13         msg = pad(msg, 16)
 14     cipher = aes.encrypt(msg)
 15     cipher = xor(cipher, key)
 16     return cipher
 17
 18 plaintext = b"FwordCTF{by_the_order_of_the_leaky_fookin_blinders}"
 19 ciphertext = encrypt(plaintext)
 20 sk = socket.socket()
 21 sk.connect(('52.149.135.130', 4869))
 22 print(key.hex())
 23 print(ciphertext.hex())
 24 print(sk.recv(4096).decode())
 25 print(sk.recv(4096).decode())
 26 sk.send(b"2\n")
 27 print(sk.recv(4096).decode())
 28 sk.send(key.hex().encode())
 29 sk.send(b"\n")
 30 print(sk.recv(4096).decode())
 31 sk.send(ciphertext.hex().encode())
 32 sk.send(b"\n")
 33 print(sk.recv(4096).decode())

And we got the flag: FwordCTF{N3v3r_x0r_w1thout_r4nd0m1s1ng_th3_k3y_0r_m4yb3_s3cur3_y0ur_c0d3}

After seeing that you might think that everything I did on the previous solution was completely unecessary and you are right, but only on this case. Even though it was unecessary, it served to demonstrate how we could devise an attack to such a vulnerable application in the real world (if we ever encounter one). All in all, it was a good source of learning…

And that’s all, folks.