Notifico

This is a writeup for how we solved the notifico 35c3 CTF challenge.

The task:

We are presented with a tar file which contains another tar file, an executable named check, and the python script check.py.
The tar file contains 225 folders, each of which contains one regular file and between 42 and 56 links to similar files in other folders.

The script check.py (python3) runs the check executable on the folder, checks that its result is 15, concatenates “magic” from the alphabetically ordered regular file’s modes (file_mode & 00777), takes the sha256 of the magic as a key and decrypts the encrypted flag with the digest. If the result contains the standard flag prefix (35C3) and the flag is made of alphanumeric characters it prints the flag.

The check executable performs 4 actions:
• runs on the folders, and creates inotify watch with mask 9 (IN_ACCESS | IN_CLOSE_WRITE) on all the regular files. Note that IN_ACCESS means that “File was accessed (e.g., read(2), execve(2)).”. During creating watchers it checks the mode of each regular file. If its write permission ^ execute permission for the owner is not 0 or if it has any of rwx permissions for other users the executable returns 255.
• Opens and closes all the regular files (which should cause IN_CLOSE_WRITE notification)
• Tries to execute all the symbolic links (which should cause IN_ACCESS notification if the file is executable)
• Reads and analyses the inotify watch results. If something was executed, it returns 255, otherwise it returns the number of close notifications received.

Initial analysis:

If we want to have something decrypted, we should find such a set of 15 folders, and links in which are not pointing to files in this set, set on files inside these folders +wx permission, and run check.py in order to check results.
In this stage it is still unclear how many combinations of this kind exist. The order in the combination doesn’t matter.
It seems like we should check results of all these sets for decryptability.

As per hint (we’ll not need it), the graph received here resembles the problem of enumeration of 15 Queens on 15×15 chess board, where the folders are places on the board [a-f][1-15].
I wasn’t able to map all the folders to exact places on the board (only the center one and main diagonals), but we don’t really need it.

We’ll bruteforce it.

The bruteforce:

During any bruteforce we usually have 3 main problems:
• performance
• performance
• and once again performance.

As far as I remember what I read somewhere, the number of unique solutions for this problem on N sized board has its own sequence name (A000170, Number of ways of placing n nonattacking queens on an n X n board
(Formerly M1958 N0775)) and can be found here: https://oeis.org/A000170
For the board 15×15 it is 365596, which is not too much, the problem is finding these solutions in reasonable time and avoiding duplicates.
I have a good workstation at home, and I can afford parallel bruteforcing, so let’s do it.

1 – Let’s construct some kind of representation of the graph.

Inside the chall folder let’s run the following shell commands:

# unpack the folder
tar -xf *.tar.gz			 
cd chall
# get list of folders into folders.txt
ls -l | cut -d" " -f 9 > ../folders.txt
cd ..
# create a listing of the folder in corresponding .fld file
for i in `cat folders.txt`; do ls -l ./chall/$i > $i.fld ; done

Now we have all the links.

2 – Let’s get the alphabetically ordered folders exactly as they are in the script

We just remove from the check script execution of the check executable, and add a print of the file name in the loop that creates the “magic”.

Here is what we got after some small editor massaging:

ind2fold = {
	0 : "APFLtDMGfSvLChWH",
	1 : "ASzhPMFMNmpBMEOm",
	2 : "AYenxPNbHIIxFaBs",
	3 : "AkpRSKtliwhtjBLO",
	4 : "AlUQWFAVMYyANtGl",
	5 : "BpFkWrUBXvwAVywH",
	6 : "BsPQryqrPSPUniAt",

	...
	...
	...

	218 : "zIzNJPYLPMyBuaWj",
	219 : "zNkvKHFVpILKhGee",
	220 : "zQiyomzHVpITGMFx",
	221 : "zUfpdFqENVnMqMbh",
	222 : "zWvRJALKMjTnoaxf",
	223 : "zoPhogrElBntiQUN",
	224 : "zsEGaUEJXslKOiZP",
}

fold2ind = {}

for i in ind2fold:
	fold2ind[ind2fold[i]] = i

3 – Let’s read the folder structure for building some graph representation:

Let’s read all the “.fld” files created earlier and analyze them as follows:

xrefto = {}
xreffrom = {}

folders_found = []
folders_dict = {}

# we get all the fld files via command line

for n in sys.argv[1:]:
	f = open(n, "r")
	res = []
	current_folder = n.replace(".fld", "")
	folders_found.append(current_folder)
	folders_dict[current_folder] = True
	for l in f:
		if l.find("-> ../") != -1: #analyzing a link
			splitted = l.split()
			res.append( splitted[-1].split('/')[1]) # getting a folder to which it points
	xrefto[current_folder] = sorted(res)
	# creating an opposite direction
	for r in sorted(res):
		if not r in xreffrom:
			xreffrom[r] = [current_folder]
		else:
			xreffrom[r].append(current_folder)
	f.close()

In this piece of code we have 2 dictionaries that represent where exactly the links point to (xrefto) and are pointed from (xreffrom).

4 – Let’s add some multiprocessing and start to avoid duplicates:

Since the order inside the combination doesn’t matter, we can conclude the following:
• We should define some order inside the combination. Alphabetical order is OK, we’ll call all combinations ordered in another way unordered which we’ll skip.
• This gives us a possibility to multi-process: during the brute-force we can start from different folders (i.e. cells of the board) and exclude what we don’t need.

This gives us the following bruteforce run:

import multiprocess
...
...
...

processes = []

folders_found = sorted(folders_found)

for i in range(len(folders_found) - 15):
	res_stack = []
	processes.append(multiprocessing.Process(target = bruteforce, args = (folders_dict.copy(), xreffrom, xrefto, 0, res_stack[:], True)))
	folders_dict.pop(folders_found[i], None) 
# note that we running on the different set each time, and removing 1 starting folder 

# per bruteforcing instance
for p in processes:
	p.start()

5 – Let’s write bruteforcing itself

# sorry, the code is ugly

def bruteforce(current_set, # places on the board left
	xrf, # xrefs from and to the places of the board collected earlier
	xrt,
	level, # current recursion level
	variant, # current variant 
	only_first): # dirty hack: see the usage

	variant = variant[:]
	current_set = current_set.copy() # copy the current set, we'll have to change it
	if level == 15:
		check(sorted(variant)) 
		# if we arrived to recursion level 15 then we have a variant, let's check it
		return False

	for f in sorted(current_set.keys()):
		# iterating the current set of places left unbeaten in alphabetical order
		new_set = current_set.copy()
		new_set.pop(f, None) # exclude current cell from the set

		# exclude fields beaten by the current cell 
		for x in xrt[f]:
			new_set.pop(x, None)

		# exclude fields that doesn't fit to alphabetical sequence

		test_set = new_set.copy()
		for t in test_set:
			if t < f:
				new_set.pop(t, None)

		if (len(new_set.keys()) + level) < 15:
			# get out if there is no enough places
			return False

		variant.append(f)
		# go down with the recursion
		bruteforce(new_set, xrf, xrt, level + 1, variant, False)
		variant.remove(f)
		if only_first:
			# on the first level we want to check only one variant
			# all others will be checked in other instances
			print("Bruteforce started from", f, " finished")
			return False
	return False

6 – Let’s copy out the checking from the check.py and fix it

#!/usr/bin/env python3
from sys import argv
import re
from hashlib import sha256
from Crypto.Cipher import AES

def check (variant):
	res = ""
	enc_flag = b'\x99|8\x80oh\xf57\xd4\x81\xa5\x12\x92\xde\xf5y\xbc\xc0y\rG\xa8#l\xb6\xa1/\xfeE\xc5\x7f\x85\x9a\x82\x0b\x02Y{\xd9/\x92X>p\\\xb7H\xb1{\xcf\x8b/r=\x87-#\xae\x95\xb6\xd1\r\x03\x13'
	flag_fmt = r"35C3_[\w]*"

	global ind2fold
	global fold2ind
	cnt = 0
	# creating the magic
	for i in range(len(ind2fold.keys())):
		if ind2fold[i] in variant:
			cnt += 1
			res += "700"
		else:
			res += "400"

	# checking for obvious mistakes,
	# we should have 15 queens and 225 cells at all

	if cnt != 15 or len(res) != 225*3:
		print ("Error!Error!Error!Error!Error!Error!Error!Error!Error!Error!Error!")
		exit(1)

	magic = res
	try:
		flag = AES.new(sha256(magic.encode()).digest(), AES.MODE_ECB).decrypt(enc_flag)
		if flag.decode().find("35C3_") != -1:
			hexdump.hexdump(flag)
		if re.fullmatch(flag_fmt, flag.decode()) is not None:
			print("Looks good, here is your output: {}".format(flag))
			# exit(0)
			# we remove the original exit because we want to know how 
# much time the full bruteforce takes
	except Exception:
		pass

	return res

7 – Let’s run it.

Actually running it with python3 may be a bit slower then we need. Let’s run it with pypy3:

pypy3.5-6.0.0-linux_x86_64-portable/bin/pypy3 multi_dict.py *.fld

It yields the flag b’35C3_congr4ts_th0se_were_s0m3_truly_w3ll_pl4c3d_perm1ssions_Sir_’

It takes some hours on my home workstation, but if we did everything right, it should fit.

Timing of the bruteforce

In order to get the flag I left this executing before going to sleep.
However I was curious about how much time exactly should it take, so I added some prints and run it again.
It appears that getting a flag takes about an hour, and full bruteforce takes about 2 hours on my computer (Ubuntu 18.0.1, i7 CoffeLake, 64G RAM)

See the output of the script below:

╰─$ ../pypy3/pypy3.5-6.0.0-linux_x86_64-portable/bin/pypy3 ./multi_dict.py *.fld
Starting
2018-12-31 21:19:26.262807
Length is 225
Bruteforce started from mEXeTszvrPQxLSIM finished
.
< - cut - >
.
Bruteforce started from FacVEEUFvQtVfjcj finished
Bruteforce started from ISOYfrwvVOMZveHE finished
Ending
2018-12-31 22:21:36.675702
400400400400400400400400400400700400400400400400400400400400400400400400400400400400700400400400400400400700400400400400400400400400400400700700400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400700400700400400400400400400400400400400400400400400400400400400400400400700400400400700400400400400400400400400400400400400400400400400700400400700400400400400700400400400400400400400400700400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400400700400700400400400400400400
00000000: 33 35 43 33 5F 63 6F 6E 67 72 34 74 73 5F 74 68 35C3_congr4ts_th
00000010: 30 73 65 5F 77 65 72 65 5F 73 30 6D 33 5F 74 72 0se_were_s0m3_tr
00000020: 75 6C 79 5F 77 33 6C 6C 5F 70 6C 34 63 33 64 5F uly_w3ll_pl4c3d_
00000030: 70 65 72 6D 31 73 73 69 6F 6E 73 5F 53 69 72 5F perm1ssions_Sir_
Looks good, here is your output: b'35C3_congr4ts_th0se_were_s0m3_truly_w3ll_pl4c3d_perm1ssions_Sir_'
Bruteforce started from FsiBPvyjOwaioLBP finished
Bruteforce started from ExCZSjfztnUcXCqh finished
Bruteforce started from GmSNArrbwMYaJsND finished
Bruteforce started from FLsGyEZReOEFwvqm finished
Bruteforce started from EQsvyIRczZVqeYkD finished
Bruteforce started from DvixmyIxrLeiXeZv finished
Bruteforce started from DqOgFKUtvPDxcDhA finished
Bruteforce started from FbLGtlhVWXDzMEEv finished
Bruteforce started from CIMHMvnbzSHPSZzw finished
Bruteforce started from DTGRkOyLvIRXOcFA finished
Bruteforce started from DvEdLxkYtbjDeMmI finished
Bruteforce started from DQiHxMFfQxEjRzak finished
Bruteforce started from BuuhlRIKIUKQixNn finished
Bruteforce started from DBdOhoYdrCrzteVm finished
Bruteforce started from AlUQWFAVMYyANtGl finished
Bruteforce started from DRlKwGefVbUJbweg finished
Bruteforce started from BsPQryqrPSPUniAt finished
Bruteforce started from BxztQECAmlZiVgLd finished
Bruteforce started from AkpRSKtliwhtjBLO finished
Bruteforce started from BpFkWrUBXvwAVywH finished
Bruteforce started from ASzhPMFMNmpBMEOm finished
Bruteforce started from AYenxPNbHIIxFaBs finished
Bruteforce started from APFLtDMGfSvLChWH finished
╭─[cenzored]@[cenzored] ~/ctfs/notifico
╰─$ date
Mon Dec 31 23:01:46 IST 2018