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 elementsmeufs[1]
shall not be strictly equal toseufs
, butstrcmp
shall returnfalse
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.
- First, the client generates some cryptographic values: a prime p, a group g and a private key sa
- Then, the client computes a public key pa from the previous values, and send (p, g, pa) to the server
- The server generates a random private key sb from the received p and g
- The server computes its own public key pb and send it to the client
- 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!