本文站在安全工程师的角度,分析了PHP中SSRF的成因及修复方案。通过在vps上作实验,介绍不同成因导致的SSRF漏洞的危害和限制,并在最后给出安全编码的示例。

cURL的配置项

此处记录了cURL的各项配置和SSRF漏洞类型之间的关系.

  • CURLOPT_HEADER 绝大多数情况下都是0,否则会连同HTTP响应头一起返回(如图

  • CURLOPT_NOBODY 启用时将不对HTML中的BODY部分进行输出,若关闭则有回显决定是否为无回显布尔型SSRF的因素

  • CURLOPT_PORT 用来指定默认连接端口。**决定SSRF是否被限制端口的因素。**多个端口:用-表示端口范围,用逗号,指定多个端口
# 指定多个端口
curl_setopt($curl, CURLOPT_PORT, 8000,9000,8081,1,2,3,4,5)

# 指定端口范围
curl_setopt($curl, CURLOPT_PORT, 8000-9000);
  • CURLOPT_PROTOCOLS 限定在传输过程中可使用的协议。这将允许你在编译libcurl时支持众多协议,默认将会使用全部它支持的协议。**决定SSRF是否被限制协议的因素. **示例代码为curl_setopt($curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS | CURLPROTO_FTP);, 多个协议用竖线|分隔. 下面是一些常用的协议, 如需完整的协议列表, 可访问https://curl.haxx.se/libcurl/c/CURLOPT_PROTOCOLS.html
可用的协议选项为:
    CURLPROTO_HTTP
    CURLPROTO_HTTPS
    CURLPROTO_FTP
    CURLPROTO_FTPS
    CURLPROTO_SCP
    CURLPROTO_SFTP
    CURLPROTO_TELNET
    CURLPROTO_LDAP
    CURLPROTO_LDAPS
    CURLPROTO_DICT
    CURLPROTO_FILE
    CURLPROTO_TFTP
    CURLPROTO_ALL

需要注意的是, 如果CURLOPT_PROTOCOLS 配置只允许HTTP协议的话, 那么是无法通过302跳转来bypass的!

也就是说302跳转只适用于关键词过滤时的绕过. 参考本文例3中的重定向型SSRF

  • CURLOPT_RETURNTRANSFERcurl_exec获取的信息以文件流的形式返回,而不是直接输出。ps: 有网友说这项配置会导致ssrf无回显,经测试并非如此——置0置1对有无输出均无影响,有无输出由_CURLOPT_NOBODY_决定** ;**实际上,这个配置项主要跟_CURLOPT_BINARYTRANSFER_一起使用)
  • CURLOPT_FOLLOWLOCATION 启用时会将服务器服务器返回的"Location: “放在header中递归的返回给服务器,使用_CURLOPT_MAXREDIRS_可以限定递归返回的数量。是否允许SSRF跳转的决定因素. 默认为False即不支持跳转
  • CURLOPT_TIMEOUT 设置cURL允许执行的最长秒数。
  • CURLOPT_TIMEOUT_MS 设置cURL允许执行的最长毫秒数。
  • CURLOPT_CONNECTTIMEOUT 在发起连接前等待的时间,如果设置为0,则无限等待。
  • CURLOPT_CONNECTTIMEOUT_MS 尝试连接等待的时间,以毫秒为单位。如果设置为0,则无限等待
  • CURLOPT_CUSTOMREQUEST 使用一个自定义的请求信息来代替"GET"或"HEAD"作为HTTP请求。常用值如"GET”,“POST”,“CONNECT"等等。

成因1—cURL

某天审一套野生cms的时候看到如上的PHP代码,curl的配置项比较多,怀疑是否存在ssrf漏洞,也正好想研究ssrf,所以对什么样的curl配置会导致ssrf这一点很感兴趣,于是展开了本次研究

0x01    回显型SSRF

实例代码

<?php
    //curl_base.php
    highlight_file(__FILE__);
    
	$url = $_GET['url'];
	$curl = curl_init($url);
	
	/*进行curl配置*/
	curl_setopt($curl, CURLOPT_TIMEOUT, 10); 	// 设置超时时间
	curl_setopt($curl, CURLOPT_HEADER, 0); // 不输出HTTP头
	//curl_setopt($curl, CURLOPT_FOLLOWLOCATION, 1); //跟随跳转
	//curl_setopt($curl, CURLOPT_NOBODY, 1) //不输出响应内容
	$responseText = curl_exec($curl);
	
	/*打印curl结果*/
	var_dump(curl_error($curl) );//如果执行curl过程中出现异常,可打开此开关,以便查看异常内容
	echo $responseText;
	
	/*关闭curl*/
	curl_close($curl);
?>

****

0x02    布尔型SSRF

示例代码

<?php
    //curl_blind.php
    highlight_file(__FILE__);
    
	$url = $_GET['url'];
	$curl = curl_init($url);
	
	/*进行curl配置*/
	curl_setopt($curl, CURLOPT_TIMEOUT, 10); 	// 设置超时时间
	curl_setopt($curl, CURLOPT_HEADER, 0); // 不输出HTTP头
	//curl_setopt($curl, CURLOPT_FOLLOWLOCATION, 1); //跟随跳转
	curl_setopt($curl, CURLOPT_NOBODY, 1) ;//不输出响应内容
	$responseText = curl_exec($curl);
	
	/*打印curl结果*/
	//var_dump(curl_error($curl) );//如果执行curl过程中出现异常,可打开此开关,以便查看异常内容
	echo $responseText;
	
	/*关闭curl*/
	curl_close($curl);
?>

这就是bool型SSRF, 返回值永远只有True or False.

True , 即有响应内容时: 返回1

False, 即无响应内容时:返回空

不妨试试, 在上面实例代码的情况下, 使用file协议仍然可以读取文件。

这是由于CURLOPT_NOBODY选项真正的作用是: 采用**HEAD****方法来请求网络资源. **就像下面cURL文档里说的一样

0x03    支持重定向的SSRF

为了演示302跳转脚本的作用, 我将cURL限制为只能请求以http://开始的地址, 实例代码如下

<?php
    //curl_location.php
    highlight_file(__FILE__);
    
	$url = $_GET['url'];
	if (preg_match("#^http:\/\/#", $url)){
	$curl = curl_init($url);
	
	/*进行curl配置*/
	curl_setopt($curl, CURLOPT_TIMEOUT, 10); 	// 设置超时时间
	curl_setopt($curl, CURLOPT_HEADER, 0); // 不输出HTTP头
	curl_setopt($curl, CURLOPT_FOLLOWLOCATION, 1); //跟随跳转
    // curl_setopt($curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTP|CURLPROTO_HTTPS|CURLPROTO_FILE); //限制cURL允许的协议
    $responseText = curl_exec($curl);
	
	/*打印curl结果*/
	//var_dump(curl_error($curl) );//如果执行curl过程中出现异常,可打开此开关,以便查看异常内容
	echo $responseText;
	
	/*关闭curl*/
	curl_close($curl);
	} else {
	    die("Only allow http://");
	}
?>

此时: 可以通过302.php脚本来实现协议的"转换”, 示例脚本如下。

<?php
//302.php
error_reporting(0);
$p = $_GET["p"];
$url = $_GET["url"];
$path = $_GET["path"] ? $_GET['path'] : '';

if(isset($url)){
    header("Location: $p://$url/$path");
}
else{
    highlight_file(__FILE__);
}
?>

注意:使用header()函数时,在<?php标签前不能有内容,否则会提示报错Warning: Cannot modify header information - headers already sent by ..., 响应头也不能正常发送。

最后,经测试,302跳转可实现HTTP=>DICT, HTTP=>GOPHER,但不支持转成FILE协议。

成因2— file_get_contents

来看看官网对这个函数的说明,支持读取以下形式的内容

  • /path/to/file.ext
  • relative/path/to/file.ext
  • fileInCwd.ext
  • C:/path/to/winfile.ext
  • C:\path\to\winfile.ext
  • \smbserver\share\path\to\winfile.ext
  • file:///path/to/file.ext

且在开启allow_url_fopen的情况下(默认开启),支持ftphttp协议

这里也是给出示例代码:

<?php
//file_get_contents.php
highlight_file(__FILE__);
$url = $_GET['url'];;
echo file_get_contents($url);
?>

当请求内网的redis服务时,报错开启的话会显示banner

当请求内网的SSH服务时也是

不知为啥,请求内网的MySQL就不行。。

相较于curl,感觉file_get_contents要收敛很多,不支持dict / gopher协议,更不支持302跳转

主要功能点在于内网的http服务扫描、本地文件的读取以及有限制的内网服务扫描(开启报错情况下的banner回显),总体来说更偏向于读取文件一些,例如下面读读PHP代码之类的。

说到读取PHP源代码,不得不提到一个东西叫做php伪协议:简单来说就是用php过滤器来将输出的内容编码,常用的编码方式之一是base64

php://filter/read=convert.base64-encode/resource=index.php

下面这张图,就是采用base64编码之后的结果。在过滤了回显关键字的时候有奇效

成因3— fopen/fsockopen

fopen

示例代码

<?php 
//fopen.php
highlight_file(__FILE__);
$file = fopen($_GET['url'], 'r');
echo fread($file, 4096);//限制读取大小 4096
fclose($file);
?>

支持file://协议,默认支持通外网,因此用来内网web服务探测也是妥妥的。

可惜的是:不支持POST请求、不支持DICTGOPHER协议。当然配合上传点,phar反序列化还是很香的。

fsockopen

<?php //fsockopen.php
highlight_file(__FILE__);
$host = $_GET[host];
$port = $_GET[port];
$fp = fsockopen($host, $port, $errno, $errstr, $timeout = 10); 
echo fgets($fp, 4096); 
  fclose($fp); 

?>

乏善可陈…

成因4—LDAP连接

通过注入CRLF来SSRF,See:https://www.silentrobots.com/blog/2019/02/06/ssrf-protocol-smuggling-in-plaintext-credential-handlers-ldap/

# nc -lvp 9000
listening on [::]:9000 ...
connect to [::ffff:127.0.0.1]:9000 from localhost:39250 ([::ffff:127.0.0.1]:39250)
0`1
2
3
4
5
6---
LibraryTested In
python-ldapPython 2.7
com.sun.jndi.ldapJDK 11
php-ldapPHP 7
net-ldapRuby 2.5.2
——-——–
  • 如果您是攻击者,并且找到LDAP配置页,请检查用户名或密码字段是否允许CRLF字符。通常,初始测试将涉及将请求发送到您控制的侦听器,以验证未过滤这些字符。
  • 如果您是防御者,请确保您的应用程序正在过滤CRLF字符(即%0D%0A

常见服务支持的协议

服务类型支持的协议实例命令响应内容
Redisdictdict://redis:6379/`-ERR Syntax error, try CLIENT (LIST
httphttp://redis:6379/-ERR wrong number of arguments for 'get' command string(0) "" 1
gophergopher://redis:6379/_info返回redis执行info命令后的内容
SSHdict
http
gopher
http://172.17.0.1:22
SSH-2.0-OpenSSH_7.4 Protocol mismatch.
MySQLdict
http
gopher
telnet
<font style="color:#262626;">dict://mysql:3306</font>
J ``**5.7.32**``q@Y60l����W{_lfD.5``**mysql_native_password!��#08S01Got packets out of order**``1
HTTPdict
http

注意:

  1. 使用dict协议, 可以直接在redis上执行命令, 如dict://redis:6379/info, 就是执行了info命令

修复建议

对于不同的需求,有不同的配置建议,但总的还是采用【白名单】的思路。

如果是为了防止SSRF进内网的话,可以通过限制协议+端口+白名单地址范围来彻底杜绝SSRF,例如下面的实例代码就是较为安全的(限制白名单地址+不输出http头+限制http(s)协议).

<?php

  $url = $_GET['url'];
  /**对url进行白名单判断,不满足就die,
  * 此处需要注意PHP中双等于'==' 以及
  * 默认配置时in_array()存在类型转换(弱类型)导致不严谨比较的问题
  * tip:PHP中,如果一个数值和字符串进行比较的时候,会将字符串转换成数值
  **/

  $whilelists = array("http://weather.com.cn", "http://baidu.com");//白名单
  $flag = in_array($url, $whilelists, TRUE)  //第三个参数为严格比较,比较时将先考察格式
  if($flag){//若url在白名单内,进行下一步配置
      $curl = curl_init($url);  
      curl_setopt($curl, CURLOPT_HEADER, 0); // 不输出HTTP响应头
      curl_setopt($curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTP|CURLPROTO_HTTPS); 
      /*	限定传输协议
       *ps:允许多种协议的配置,用|隔开即可
       */
      curl_setopt($curl, CURLOPT_PORT, 80); //限定访问端口
      curl_setopt($curl, CURLOPT_FOLLOWLOCATION, 0);
      $responseText = curl_exec($curl);
      echo $responseText;
      curl_close($curl);
} else{
 die("url不在白名单内!");
 }

?>

Fuzz: cURL 7.61.1支持的协议

可用于fuzz,参考http://www.codersec.net/2020/05/SSRF%E6%94%BB%E5%87%BB%E5%A7%BF%E5%8A%BF%E6%B1%87%E6%80%BB/

dict
file
ftp
ftps
gopher
http
https
imap
imaps
ldap
ldaps
pop3
pop3s
rtsp
smb
smbs
smtp
smtps
telnet
tftp