BreizhCTF 2k18 Write Up

Apr 23, 2018   #Hack  #Write-up 

For a second year in a row, I participated in the BreizhCTF competition in Rennes (Brittany, France). Over a full night, 60 teams of 5 competitors attempted to break over 60 hacking challenges. Check my NdH wargame explanation for more info.

Last year, I worked with the Amossys’ Team, but this time I was with the Tilde ~ team, and we managed to rank 7th with 3501 points! Here is the aftermovie:

My teammates write-ups:

Dickcrypt

We can start with challenge with a python file and a (ip, port) couple. For this write-up, we changed the last line and the KEY constant to something we know.

from Crypto.Cipher import AES
import base64

KEY = "REDACTED"
BLOCK_SIZE = 16
pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * \
                chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)
unpad = lambda s: s[:-ord(s[len(s) - 1:])]

def encrypt(plain):
    cipher = ""
    for char in plain:
        cipher = "%s%s" % (cipher, bin(ord(char))[2:])

    plain = pad(cipher)
    print(plain)
    algo = AES.new(KEY, AES.MODE_ECB)
    return algo.encrypt(plain)

if __name__ == "__main__":
    finished = False
    print("(exit to quit, other will be ciphered)")
    while not finished:
        data_in = raw_input(">>> ")
        if data_in == "exit":
            finished = True
            continue
        print(base64.b64encode(encrypt(data_in)))

"""
Uncipher me : lBQBGY0jienD2qZk6Mg0n6KKHo2x2e5HMI0ckwuUfeELx0/Ttqa0lEfA70e2ZPnGTPvlydhi6ead2JUieNIwExs5FCHrW6PhXqOuV72bpmhQm3nRSqtfUGSoNqvHHHSMlX24adLfmJQNonJ/J1cmhIszeqRwFmzpZu9VpI90qUGe3zgC8BM7614ddllPFTVTLmKMrjd1ua3GZXzC4rsEIhDfrBQ9fYlHpZLtU1Pqs78=
"""

First of all, let’s analyze that code. It seems to encrypt whatever string we throw at it in AES-128-ECB mode. The interesting part is in the beginning of the encrypt function:

    for char in plain:
        cipher = "%s%s" % (cipher, bin(ord(char))[2:])

Each character of the cleartext is encoded in binary, but with no 0 prefix. For instance, a becomes 1100001 and - becomes 101101. One AES block will hold 16 bytes, a little more than 2 characters in the initial cleartext. That’s pretty good, because we can easily brute-force that encryption scheme to decipher the given ciphertext.

It is pretty obvious that the flag is encrypted and given as the ciphertext, and we can check that really quickly:

$ nc <ip> <port>
(exit to quit, other will be ciphered)
>>> bzhctf{
lBQBGY0jienD2qZk6Mg0n6KKHo2x2e5HMI0ckwuUfeELx0/Ttqa0lEfA70e2ZPnGPTURSgAS6QnoxqjnG9yRwg==

It was quite difficult to create an automatic brute-force script, because there may be several candidates at each iteration. We decided to go the “manual” way with the following Go program:

package main

import (
	"bufio"
	"encoding/base64"
	"fmt"
	"net"
	"os"
	"strconv"
)

func ce(err error) {
	if err != nil {
		panic(err)
	}
}

func nBlocks(cleartext string) (blocks, remaining int) {
	var n int
	for _, c := range []byte(cleartext) {
		n += len(strconv.FormatInt(int64(c), 2))
	}

	blocks = n / 16
	remaining = n % 16
	return
}

const rawCiphertext = "lBQBGY0jienD2qZk6Mg0n6KKHo2x2e5HMI0ckwuUfeELx0/Ttqa0lEfA70e2ZPnGTPvlydhi6ead2JUieNIwExs5FCHrW6PhXqOuV72bpmhQm3nRSqtfUGSoNqvHHHSMlX24adLfmJQNonJ/J1cmhIszeqRwFmzpZu9VpI90qUGe3zgC8BM7614ddllPFTVTLmKMrjd1ua3GZXzC4rsEIhDfrBQ9fYlHpZLtU1Pqs78="

const charset = "0123456789abcdefghijklmnopqrstuvwxyz._-}ABCDEFGHIJKLMNOPQRSTUVWXYZ"

func main() {
	// Decode ciphertext
	ciphertext, err := base64.StdEncoding.DecodeString(rawCiphertext)
	ce(err)

	// Prepare TCP connection
	conn, err := net.Dial("tcp", "ip:port")
	ce(err)
	reader := bufio.NewReader(conn)
	reader.ReadString('\n')

	// Compute full blocks found and ask for target number of blocks
	cleartext := os.Args[1]
	blocksFound, remainingBytes := nBlocks(cleartext)
	fmt.Println("Full blocks found:", blocksFound, "(+", remainingBytes, "bytes)")
	inReader := bufio.NewReader(os.Stdin)
	fmt.Print("Blocks target? ")
	s, _ := inReader.ReadString('\n')
	targetBlocks, err := strconv.Atoi(string(s[:len(s)-1]))

	// Bruteforce
	fmt.Println("Running...")
	for _, i := range charset {
		for _, j := range charset {
			for _, k := range charset {
				newCT := cleartext + string(i) + string(j) + string(k)
				fmt.Fprintf(conn, newCT+"\n")
				res, err := reader.ReadString('\n')
				ce(err)

				decoded, err := base64.StdEncoding.DecodeString(res[4:])

				var n int
				for l := 0; l < len(decoded); l++ {
					if decoded[l] != ciphertext[l] {
						n = l
						break
					}
				}

				if n/16 >= targetBlocks {
					fmt.Println(newCT)
				}
			}
		}
	}

	fmt.Println("Done")
	conn.Close()
}

And here is how we used it:

As you may have noticed, this is not an efficient method. It was really slow on the saturated network of the event, and we spent a lot of time looking at other solutions, without success. However, at one point (we already had half the flag), the organizers decided to relax this challenge by changing a simple line to align cleartext bytes and AES blocks:

cipher = "%s%s" % (cipher, "0"+bin(ord(char))[2:])

In a matter of minutes, with this simple modification, we were the first team to validate this flag 🥇

Use the luck, force

This challenge was very similar to the previous one. Again, one script and one server:

from Crypto.Cipher import AES
from time import gmtime, strftime
import random
import base64
import string
import sys

KEY = "REDACTED"
SECRET = "REDACTED"
BLOCK_SIZE = 16
pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * \
                chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)
unpad = lambda s: s[:-ord(s[len(s) - 1:])]

def create_iv():
    """
    IV is not very important, randomization based on seconds is ok
    """
    seconds = int(strftime("%S", gmtime()))
    length = seconds
    iv = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(length))
    if len(iv) > 1 and len(iv) < 16:
        pad_iv = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(BLOCK_SIZE - len(iv)))
        iv = "%s%s" % (iv, pad_iv)
    return iv

def encrypt(plain):
        """
        Create cipher AES + CBC
        """
        plain = pad("%s%s%s" % (create_iv(), plain, SECRET))
        cipher = AES.new(KEY, AES.MODE_CBC, plain[:BLOCK_SIZE])
        return base64.b64encode(plain[:BLOCK_SIZE] + cipher.encrypt(plain[BLOCK_SIZE:]))

if __name__ == "__main__":
	resp = raw_input('>>> ')
	print encrypt(resp)

Here, we had to find the original value of the SECRET variable. The encryption is AES-128-CBC this time, with a custom IV generation function (bad idea…).

The script returns the IV, followed by the encryption of plain || SECRET. The following lines relies on the fact that the IV will always have a length of 16 bytes:

return base64.b64encode(plain[:BLOCK_SIZE] + cipher.encrypt(plain[BLOCK_SIZE:]))

The IV is constructed from the current seconds, and is padded if its original size is greater than 1 and lower than 16. However, it is possible to have an iv of size 0 if seconds == 0. In that case, the padding condition is not triggered. We can manipulate the input to remove the plain variable and return the first 16 bytes of the SECRET variable.

If seconds > 0:

+------------------+-------------+--------------+
|   IV (16 bytes)  |    Plain    |    Secret    |
+------------------+-------------+--------------+
|    Cleartext     |  Encrypted using KEY, IV   |

If seconds == 0 and plain is empty:

+++-------------------------------+
|||            Secret             |
+++-------------------------------+
  |    Cleartext     |  Encrypted |

We just had to build a tiny script that calls our faulty server to get those 16 first bytes on second 0:

package main

import (
	"bufio"
	"fmt"
	"net"
	"time"
)

func ce(err error) {
	if err != nil {
		panic(err)
	}
}

func run() {
	conn, err := net.Dial("tcp", "ip:port")
	ce(err)

	reader := bufio.NewReader(conn)

	fmt.Fprintf(conn, "\n")
	res, err := reader.ReadString('\n')
	ce(err)

	fmt.Println(res)
	conn.Close()
}

func main() {
	for i := 0; i < 150; i++ {
		time.Sleep(500 * time.Millisecond)
		run()
	}
}

We got a number of results, and looked for the smallest lines:

[...]
>>> TDJOUzVDTzJPVjkwSlMxWNrBul6An91TEgCFTa3P1kOtf7NhAxKXpZMXRckhXlDwLntQ0nAAZizmnnk1IFKLvBJnjUzQg8Cs5aWY6VElgRo=
>>> T1o2NUtUMjVCWlBNM04wMHTrWXVDk4D0As48TyACmLfefkhnFyRgNeYnEKLCp/a/v+ESUotKrwjGvOGg2AHSzIRctvVLtUcb/chGm4UHezM=
>>> MTRKTjFPR0haQjFFOUpZSjQgZbpGg/DB2dXMMNVIO/FTibwUfhrFlNFakzLJCQFYwpiD8vqc6wtYYam9anS2+L7Jag6pg4XFvnm7DRa1xDM=
>>> YnpoY3Rme2ZsNGd9BAQEBA==
[...]
$ echo -n "YnpoY3Rme2ZsNGd9BAQEBA==" | base64 -d
bzhctf{fl4g}

Hopefully, the length of the given flag was less than 16 bytes, so we did not have to dig further 😁

Chinoiseries

We were provided one webpage for this challenge, that displayed its own source code.

<?php
$i01=0;
$i02=0;
$i03=0;

$a=(array)json_decode(@$_GET['kaaris']);

if(is_array($a)){
  is_numeric(@$a["Jte_laisse_tirer_sur_ma_chicha"])?die("brrrrah"):NULL;
  if(@$a["Jte_laisse_tirer_sur_ma_chicha"]){
    ($a["Jte_laisse_tirer_sur_ma_chicha"]>2017) ? $i01=1 :NULL;
  }

  if(is_array(@$a["et_tu_veux_plus_me_rendre_le_tuyau"])){
    if(count($a["et_tu_veux_plus_me_rendre_le_tuyau"])!==7 OR !is_array($a["et_tu_veux_plus_me_rendre_le_tuyau"][0])) die("brrrrah");
    $pos = array_search("Diarabi, diarabi, diarabi, ma chérie", $a["a2"]);
    $pos===false?die("brrrrah"):NULL;
    foreach($a["et_tu_veux_plus_me_rendre_le_tuyau"] as $key=>$val){
      $val==="Diarabi, diarabi, diarabi, ma chérie"?die("brrrrah"):NULL;
    }
    $i02=1;
  }
}

$c=@$_GET['meufs'];
$d=@$_GET['seufs'];
if(@$c[1]){
  if(!strcmp($c[1],$d) && $c[1]!==$d){
    eregi("3|1|c",$d.$c[0])?die("brrrrah"):NULL;
    strpos(($c[0].$d), "Diarabi, diarabi, faut qu'on fasse du wari")?$i03=1:NULL;
  }
}

if($i01 && $i02 && $i03){
  include "flag.php";
  echo $FL4G;
}
?>

Starting from the end, we knew we had to inject various $_GET parameters to unlock the last condition, printing our beloved $FL4G variable. We copied this source code to a local file, and started an embedded PHP server to test our payloads.

$ php -S localhost:8000

Unlocking $i01

This is a simple challenge, we can process row by row to find one valid payload. We know from the first lines that $_GET['kaaris'] shall be a json encoded object, with the following specifications:

  • It should contain a non-numeric value for the “Jte_laisse_tirer_sur_ma_chicha” key
  • This value should be greater than 2017

If we use a string as the value for this key, PHP will convert 2017 to a string and do a simple string comparison. We can unlock the first variable with this simple payload for instance:

?kaaris={"Jte_laisse_tirer_sur_ma_chicha": "2018A"}

Unlocking $i02

We need to add more specifications to our json object:

  if(is_array(@$a["et_tu_veux_plus_me_rendre_le_tuyau"])){
        if(count($a["et_tu_veux_plus_me_rendre_le_tuyau"])!==7 OR !is_array($a["et_tu_veux_plus_me_rendre_le_tuyau"][0])) die("brrrrah");
  • It shall contain a “et_tu_veux_plus_me_rendre_le_tuyau” key
  • This value of this key shall be an array of length 7
  • The first element of the value shall itself be an array
    $pos = array_search("Diarabi, diarabi, diarabi, ma chérie", $a["a2"]);
    $pos===false?die("brrrrah"):NULL;
    foreach($a["et_tu_veux_plus_me_rendre_le_tuyau"] as $key=>$val){
      $val==="Diarabi, diarabi, diarabi, ma chérie"?die("brrrrah"):NULL;
    }
  • The json object shall also contain a “a2” key, which is an array containing “Diarabi, diarabi, diarabi, ma chérie”
  • However, our previous array shall not contain this strange string.

We can easily find a payload for these additional specifications:

?kaaris={"et_tu_veux_plus_me_rendre_le_tuyau":[[], 1, 2, 3, 4, 5, 6], "a2": ["Diarabi, diarabi, diarabi, ma chérie"]}

Unlocking $i03

For this last lock, we need to provide to additional $_GET parameters: meufs as $c and seufs as $d. There is a special condition we need to pass :

  if(!strcmp($c[1],$d) && $c[1]!==$d){
    strpos(($c[0].$d), "Diarabi, diarabi, faut qu'on fasse du wari")?$i03=1:NULL;
  }
  • meufs shall be an array of at least 2 elements
  • meufs[1] shall not be strictly equal to seufs, but strcmp shall return false between those two values (meaning that there are two “identical strings”)
  • meufs[0] shall be prefixed by some string followed by “Diarabi, Diarabi, faut qu’on fasse du wari”

Strcmp has a known vulnerability when one of its operand is not a string. By using $_GET parameters, we can only inject strings, so we cannot set $d as an integer. However, we can set it to an array, and use the following property of strcmp: strcmp("foo", array()) => NULL.

?meufs[]=xDiarabi, diarabi, faut qu'on fasse du wari&meufs[]=b&seufs[]=

We just had to merge those 3 payloads to obtain the flag.

Diffie-Failman - strike back

This is a rework of the BreizhCTF 2k17 Diffie-Failman challenge I already broke last year, so this one was pretty easy :) We got one pcap file and one python script for this challenge:

from Crypto.Cipher import AES
from Crypto import Random
from random import randint
import hashlib
import socket
import json
import sys

size = AES.block_size
server = "0.0.0.0"
is_server = True
shared_key = ''

pad = lambda s: s + (size - len(s) % size) * chr(size - len(s) % size)
unpad = lambda s : s[0:-ord(s[-1])]

def encrypt(message, key):
    message = pad(message)
    IV = Random.new().read(size)
    aes= AES.new(key, AES.MODE_CBC, IV)
    return "%s%s" % (IV, aes.encrypt(message))

def decrypt(message, key):
    IV = message[:size]
    aes = AES.new(key, AES.MODE_CBC, IV)
    return unpad(aes.decrypt(message[size:]))

def is_prime(num, test_count):
    if num == 1:
        return False
    if test_count >= num:
        test_count = num - 1
    for x in range(test_count):
        val = randint(1, num - 1)
        if pow(val, num-1, num) != 1:
            return False
    return True

def generate_prime(n):
    found_prime = False
    while not found_prime:
        p = randint(2**(n-1), 2**n)
        if is_prime(p, 1000):
            return p

def derivate_psk(psk):
    return hashlib.sha256(str(psk)).digest()


if __name__ == "__main__":

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    if len(sys.argv) > 1:
        server = sys.argv[1]
        is_server = False

    try:
        if is_server:
            print("Start server")
            s.bind((server, 31337))
            while True:
                s.listen(1024)
                client, accept = s.accept()
                key_exchange_data_json = client.recv(1024)
                key_exchange_data = json.loads(key_exchange_data_json)
                private_key = randint(2**(size-1), 2**size)
                public_key = (key_exchange_data["group"]**private_key) % key_exchange_data["prime"]
                client.send(json.dumps({"public_key": public_key}))
                pre_shared_key = (key_exchange_data["public_key"] ** private_key) % key_exchange_data["prime"]
                shared_key = derivate_psk(pre_shared_key)
                while True:
                    secret_message = client.recv(1024)
                    message = decrypt(secret_message, shared_key)
                    print("<<< %s" % message)
                    message = raw_input(">>> ")
                    secret_message = encrypt(message, shared_key)
                    client.send(secret_message)
        else:
            print("Connect to %s" % server)
            s.connect((server, 31337))
            prime = generate_prime(size)
            group = randint(2**(size-1), 2**size)
            private_key = randint(2**(size-1), 2**size)
            public_key = (group**private_key) % prime
            key_exchange_data = {"prime": prime,
                                 "group": group,
                                 "public_key": public_key}
            s.send(json.dumps(key_exchange_data))
            key_exchange_data_json = s.recv(1024)
            key_exchange_data = json.loads(key_exchange_data_json)
            pre_shared_key = (key_exchange_data["public_key"] ** private_key) % prime
            shared_key = derivate_psk(pre_shared_key)
            while True:
                message = raw_input(">>> ")
                secret_message = encrypt(message, shared_key)
                s.send(secret_message)
                secret_message = s.recv(1024)
                message = decrypt(secret_message, shared_key)
                print("<<< %s" % message)

    except NotImplementedError:
        s.close()
    s.close()

It is important to read the main part of the script to understand how the pcap file (“network packets capture”) was generated. Our script can work in server or client mode.

  1. First, the client generates some cryptographic values: a prime p, a group g and a private key sa
  2. Then, the client computes a public key pa from the previous values, and send (p, g, pa) to the server
  3. The server generates a random private key sb from the received p and g
  4. The server computes its own public key pb and send it to the client
  5. Both parts compute a shared key sk using their own private key and the other part’s public key (this is a standard Diffie-Hellman key exchange).

One interesting thing, is that random private keys are generated between 32768 and 65536, which is a very small space. We juste have to fetch (p, g, pa) to guess sa, or p, g, pb to guess sb. We went for the second one by analyzing the first two exchanged messages:

Client > Server: {"prime": 47143, "public_key": 1691, "group": 33776}
Server > Client: {"public_key": 22936}

We needed to find sb for pb = (g ** sb) % p or 22936 = (33776 ** sb) % 47143

import sys

if __name__ == "__main__":
    for i in range(2**15, 2**16):
        res = (33776**i) % 47143
        print(i) # Result: sb = 34240
        if res == 22936:
            print("OK")
            sys.exit()

After finding the server private key, we rewrote the main function to be able to compute the shared key and then read cleartext messages!

if __name__ == "__main__":
    private_key = 34240
    pre_shared_key = (1691 ** private_key) % 47143
    shared_key = derivate_psk(pre_shared_key)

    payload = "<ciphertext from network capture>".decode("hex")
    print decrypt(payload, shared_key)

And we immediately got our flag:

Hey, I can give you the flag now : bzhctf{D1ff13_1s_n0t_my_fr13nd}

Bonus

We found a small issue with the BreizhCTF scoreboard that internally uses the amazing CTFd project: only very basic email addresses were accepted during registration; so we were unable to use our fancy emoji-powered email addresses 🐷

Validating emails is not an easy task, and one shall make some trade-offs between security and RFC compliance. Check the issue for more!