前言
学校举办的第九届极客大挑战,其中一道根据 Blackhat 议题出的 ssrf 题目,也是第一次尝试阅读 php 源码,望大牛们勿喷
代码分析
<?php
function check_inner_ip($url)
{
$match_result=preg_match('/^(http|https)?:\/\/.*(\/)?.*$/',$url);
if (!$match_result)
{
die('url fomat error1');
}
try
{
$url_parse=parse_url($url);
}
catch(Exception $e)
{
die('url fomat error2');
}
$hostname=$url_parse['host'];
echo $url_parse['host'];
$ip=gethostbyname($hostname);
$int_ip=ip2long($ip);
return ip2long('127.0.0.0')>>24 == $int_ip>>24 || ip2long('10.0.0.0')>>24 == $int_ip>>24 || ip2long('172.16.0.0')>>20 == $int_ip>>20 || ip2long('192.168.0.0')>>16 == $int_ip>>16 || ip2long('0.0.0.0')>>24 == $int_ip>>24;
}
function safe_request_url($url)
{
if (check_inner_ip($url))
{
echo $url.' is inner ip';
}
else
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, 0);
$output = curl_exec($ch);
$result_info = curl_getinfo($ch);
if ($result_info['redirect_url'])
{
safe_request_url($result_info['redirect_url']);
}
curl_close($ch);
var_dump($output);
}
}
$url = $_POST['url'];
if(!empty($url)){
safe_request_url($url);
}
else{
highlight_file(__file__);
}
//hint23333:
//flag in flag.php
//phpinfo in phpinfo.php
?>
check_inner_ip
通过 url_parse
检测是否为内网 ip 。
如果满足不是内网 ip ,通过 curl
请求 url 返回结果。
这是 github 上开源,根据 p师傅文章写的防御 ssrf 攻击代码,详情可以查看:安全编码系列–ssrf漏洞防御脚本
漏洞利用
乍一看好像并没有利用点,跳转也做了处理,最终都要经过 check_inner_ip
函数检测。但是忽略了 php_url_parse
和 curl
同时处理 url 不同。
【Blackhat】SSRF的新纪元:在编程语言中利用URL解析器
这里面关于 curl 的利用提到了,当处理这个地址时
curl 和 php_url_parse 处理后最终的目标不一样
当 php_url_parse
认为 google.com
为目标的同时,curl
认为 evil.com:80
是目标。
文章作者向 curl 团队报告了这个问题,得到了一个补丁,但是补丁又可以通过空格的方式绕过。
有趣的是当作者再次向官方团队报告漏洞时,被告知它本来就是要让你来传给他正确的URL参数的,并且他们表示,这个漏洞不会修复。?
我们再来分析一下代码逻辑,检测是否内网 ip 通过 parse_url
,而最后请求是用 curl
完成的。当遇到上面的 url 格式时,parse_url
判断的是第二个 @ 后接的地址,curl 请求的是第一个。
于是利用思路就有了,让 parse_url
处理外部网站,最后 curl
请求内网网址。
构造 payload:http://Str3am@127.0.0.1:80 @www.baidu.com/flag.php
漏洞分析
题目环境是 php 7.0.32,本地使用 phpstudy 搭建,使用 php 7.0.12,这里也就此版本分析。
这里也推荐一篇翻译的关于 php 源码阅读的基础指南:phps-source-code-for-php-developers
parse_url
函数申明位于 /ext/standard/url.h
,具体可以访问 https://github.com/php/php-src/blob/PHP-7.0.12/ext/standard/url.h
具体定义位于 /ext/standard/url.c
主要函数 php_url_parse_ex()
从 97 行开始
这里迫于篇幅,不再对具体过程详解,这篇文章关于源码的详解很精彩 PHP源码分析之parse_url()的2个小trick
str
为处理的 url
,s
、e
、p
、pp
、ue
字符指针,用于标记 url 中字符位置,也是这个函数处理的大致方式。先提取协议(scheme)如 http,https 并存储,接着获取请求参数(query)和锚点(fragment),获取端口(port)最后检测主机(host)并存储。
243 行检测 user
和 pass
,对于 http://Str3am@127.0.0.1:80 @www.baidu.com/flag.php
,此时变量 e
指向末尾的 p
,变量 s
指向 http://
之后的 S
。(e-s)
代表所指字符之间的内容(包含),所以函数 zend_memrchr
处理的内容即 Str3am@127.0.0.1:80 @www.baidu.com/flag.php
问题就出在这个 zend_memrchr
函数,定义位于 /Zend/zend_operators.h
,193 行,是从字符串最末尾开始检测,那么对于两个 @
,就会解析到最后一个
跟着下来,最后获取主机(host)的时候,(p-s)
的内容即 www.baidu.com
最后的解析结果如下
curl
定义位于 /ext/curl/php_curl.h
,实现位于 /ext/curl/interface.c
,这里是对 libcurl
经行调用,由于能力原因就不继续往下分析了。
另一道非预期解
比赛时因为非预期被下了,这里也来分析一波
<?php
//error_reporting(E_ALL);
function check_inner_ip($url)
{
$match_result=preg_match('/^(http|https)?:\/\/.*(\/)?.*$/',$url);
if (!$match_result)
{
die('url fomat error');
}
try
{
$url_parse=parse_url($url);
}
catch(Exception $e)
{
dir('url fomat error');
return false;
}
$hostname=$url_parse['host'];
//var_dump($hostname);
$ip=gethostbyname($hostname);
$int_ip=ip2long($ip);
return ip2long('127.0.0.0')>>24 == $int_ip>>24 || ip2long('10.0.0.0')>>24 == $int_ip>>24 || ip2long('172.16.0.0')>>20 == $int_ip>>20 || ip2long('192.168.0.0')>>16 == $int_ip>>16;
}
function safe_request_url($url)
{
if (check_inner_ip($url))
{
echo $url.' is inner ip';
}
else
{
//var_dump($url);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, 0);
$output = curl_exec($ch);
$result_info = curl_getinfo($ch);
if ($result_info['redirect_url'])
{
safe_request_url($result_info['redirect_url']);
}
curl_close($ch);
var_dump($output);
}
}
$url = $_POST['url'];
if(!empty($url)){
safe_request_url($url);
}
else{
highlight_file(__file__);
}
//hint23333:
//flag in flag.php
//phpinfo in phpinfo.php
?>
一开始对比代码,以为是 try..catch
块里的 return false
造成非预期。在师傅的指点下发现函数 check_inner_ip()
中少了对 0.0.0.0
过滤,于是试了一下 url=http://0.0.0.0/flag.php
,发现竟然可以,但是在本机 windows
环境下复现时却不行。
查阅了相关资料,0.0.0.0
代表本机 ipv4 的所有地址,猜测可能发布的时候绑定用的 0.0.0.0
,这样做包括本地ip和外网ip都能访问到服务,导致问题。
后记
这道题也可以使用 DNS 重绑定(DNS rebinding)解决,有兴趣的师傅们可以去了解下,个人感觉可能是 ssrf 的通解了