查询 25 次,耗时 0.2230 秒

别把 X-Forwarded-For 当身份证:本地复现代理头伪造,并加固 Nginx 的客户端 IP 信任边界

kmapaypay

New member

kmapaypay Rep
0
0
0
Rep
0
kmapaypay Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
kmapaypay 获得
0
0
0
获得
0
kmapaypay 凭证
0
0
0
凭证
0
主题
3
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
大家好,我是 kmapaypay。这篇做一个可以在本机复现的 Web 安全实验:为什么网站明明放在反向代理后面,攻击者仍然可能伪造“客户端 IP”?以及我们应该在哪一层修复。

这个问题不是某个框架独有的漏洞,而是典型的信任边界错误。它会影响访问日志、按 IP 限流、风控判断,严重时还会影响错误设计的管理接口访问控制。下面只使用本地 Docker 环境,不扫描公网,也不测试第三方站点。

一、背景:三个经常被混为一谈的地址

当用户通过 Nginx 访问后端应用时,通常存在三种“地址”:

1. 用户与入口代理建立连接时,入口代理看到的连接源地址。
2. 后端应用收到连接时看到的对端地址,通常是 Nginx 容器地址。
3. HTTP 请求头中声称的客户端地址,例如 X-Forwarded-For、X-Real-IP 或 Forwarded。

前两项属于相应网络连接的上下文,第三项只是请求数据。请求头不会因为名字里有 Forwarded 就自动可信。

常见错误是:应用读取 X-Forwarded-For,把逗号前第一项当成真实客户端 IP,然后据此决定是否允许访问。这相当于把用户填写的“我是管理员”当成管理员凭证。

二、原理:追加请求头不等于清洗请求头

Nginx 中经常见到下面的配置:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

这个变量会把现有的 X-Forwarded-For 与当前连接源地址拼接。如果请求原本带着:

X-Forwarded-For: 127.0.0.1

转发后的内容可能类似:

127.0.0.1, 172.20.0.1

如果应用直接取第一项,就会相信攻击者自己填写的 127.0.0.1。

注意:追加方式本身不必然构成漏洞。在受控的多层代理链中,它可以配合严格的可信代理解析策略使用。真正的问题是“代理没有明确清洗边界,应用却无条件信任最左侧值”。

三、前置条件与实验边界

需要安装 Docker、Docker Compose 插件,以及 curl。首次运行需要能够获取 Python 和 Nginx 官方镜像。下面使用的标签会随上游维护而变化;正式工程应验证版本后固定镜像摘要。

本实验只把入口绑定到宿主机 127.0.0.1:8080,后端不映射宿主机端口。不要为了让别人远程访问而改成 0.0.0.0,也不要直接把这个演示应用部署到公网。

实验中的 /admin-check 只返回“模拟允许或拒绝”,不执行管理操作。特意保留一段错误逻辑,是为了观察漏洞机制,不是推荐实现。

四、分步骤搭建

步骤 1:建立目录

mkdir proxy-ip-lab
cd proxy-ip-lab

步骤 2:创建 app.py,内容如下。保留 Python 缩进:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
def do_GET(self):
xff = self.headers.get('X-Forwarded-For', '')
claimed_ip = xff.split(',')[0].strip() if xff else self.client_address[0]
data = {
'socket_peer': self.client_address[0],
'received_xff': xff,
'received_x_real_ip': self.headers.get('X-Real-IP'),
'received_forwarded': self.headers.get('Forwarded'),
'claimed_ip_by_bad_parser': claimed_ip
}
if self.path == '/admin-check':
data['demo_only_allowed'] = claimed_ip == '127.0.0.1'
body = json.dumps(data, ensure_ascii=False).encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)

HTTPServer(('0.0.0.0', 8000), Handler).serve_forever()

这里 HTTP 200 只表示诊断请求处理成功,是否“模拟允许”请看 demo_only_allowed 字段。HTTPServer 也仅用于实验,不适合直接承担生产流量。

步骤 3:创建 compose.yaml:

services:
app:
image: python:3.12-slim
working_dir: /app
command: ["python", "-u", "app.py"]
volumes:
- ./app.py:/app/app.py:ro
nginx:
image: nginx:stable-alpine
ports:
- "127.0.0.1:8080:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app

步骤 4:创建存在信任边界缺陷的 nginx.conf:

events {}
http {
server {
listen 80;
location / {
proxy_pass http://app:8000
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}

步骤 5:启动并检查:

docker compose up -d
docker compose ps
curl -sS http://127.0.0.1:8080/admin-check

正常情况下,返回 JSON 中 demo_only_allowed 为 false。socket_peer 是应用看到的代理地址,不是浏览器地址;received_xff 则是 Nginx 写入的地址。

不同系统下,Docker Desktop、桥接网络和 NAT 的具体表现可能不同,因此不要照抄某个预期 IP。我们的判断依据是字段之间的关系,以及伪造输入能否控制最终判断。

五、在本地复现伪造效果

执行:

curl -sS -H 'X-Forwarded-For: 127.0.0.1' http://127.0.0.1:8080/admin-check

观察三件事:

第一,received_xff 通常包含你提交的 127.0.0.1,后面再跟上 Nginx 看到的连接源地址。
第二,claimed_ip_by_bad_parser 变成了 127.0.0.1。
第三,demo_only_allowed 变成 true。

整个过程中,真实连接来源没有因请求头而改变。改变的只是应用愿意相信的数据。这就是漏洞根因。

可以再提交一个文档示例地址进行对照:

curl -sS -H 'X-Forwarded-For: 192.0.2.123' http://127.0.0.1:8080/admin-check

此时解析结果应变成 192.0.2.123,模拟允许恢复为 false。这个对照说明错误解析器实际上在接受请求者提供的身份声明。

六、加固:让入口代理重新建立可信元数据

当前拓扑只有一层对外入口代理,前面没有 CDN 或负载均衡。因此可以由 Nginx 覆盖外部提交的客户端地址头。

把 nginx.conf 的 location 部分改为:

location / {
proxy_pass http://app:8000
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Forwarded "";
}

这里有三个动作:

1. 用 Nginx 当前连接源地址覆盖 X-Forwarded-For,不再保留请求者提交的前缀。
2. 同步覆盖 X-Real-IP,避免应用某个组件切换头名称后再次接受伪造内容。
3. 清除本实验不用的 Forwarded,避免另一套地址来源与既定策略冲突。

空字符串配置会使 Nginx 不向上游传递对应头。不要认为处理了 X-Forwarded-For,就自动处理了所有框架可能读取的代理头。

先检查配置,再重新加载:

docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload

重新执行伪造请求,并同时带上其他头:

curl -sS -H 'X-Forwarded-For: 127.0.0.1' -H 'X-Real-IP: 127.0.0.1' -H 'Forwarded: for=127.0.0.1' http://127.0.0.1:8080/admin-check

预期结果:received_xff 和 received_x_real_ip 来自 Nginx 看到的连接源地址,received_forwarded 为 null,伪造值不能再控制 claimed_ip_by_bad_parser。

在常见 Docker 桥接环境中,demo_only_allowed 也会恢复为 false;但不要把这个布尔值当作唯一验收标准。如果网络实现恰好让代理看到回环地址,仍应以“输出是否摆脱输入控制”为核心判断。

七、生产环境还必须补上的防线

第一,应用端口不能被外部绕过代理直连。否则攻击者可以直接向应用提交伪造头,入口清洗就没有意义。本实验没有发布 app 的端口,但同一 Docker 网络内的其他容器通常仍可能访问它;生产环境需要继续隔离非可信工作负载。

第二,应用或框架必须只信任明确的代理来源。不要为了获取真实 IP,把所有地址都配置成可信代理。具体设置取决于框架,应依据其官方文档,并检查实际中间件顺序。

第三,IP 不是身份认证。修复后也应删除示例中“127.0.0.1 等于管理员”的设计。管理权限应依赖经过验证的会话、身份与授权策略;IP 白名单最多作为附加约束。

第四,如果 Nginx 前面还有 CDN 或负载均衡,不能直接照搬单层代理方案。此时 $remote_addr 默认可能只是上一跳代理地址。应先限制源站仅允许可信上游接入,再依据上游的文档选择真实地址头,并将 real_ip 信任范围限定到实际代理网段。不要使用覆盖全网的 set_real_ip_from 来图省事。

第五,安全日志应区分连接对端、可信解析后的客户端地址,以及未经信任的原始头。原始头只能作为待分析输入,不能直接承担身份判定,也要防止控制字符污染日志。

八、常见问题

问:直接取 X-Forwarded-For 最右边是不是就安全?
答:不能通用化。多层代理、不同转发策略、可变路径都会影响地址链。正确方法是定义可信代理集合,从已确认的代理边界按框架规则解析,而不是永远取第一项或最后一项。

问:为什么修复后日志仍显示 Docker 网关地址?
答:因为容器入口看到的连接可能经过宿主机转发。这不一定是配置错误。先确认拓扑,不要为了得到“看着像真实用户”的地址,重新信任用户提交的头。

问:修复代理头后,按 IP 限流就足够了吗?
答:仍然不够。共享出口会让多个用户共用一个地址,IPv6 和代理网络也会改变地址分布。应按业务结合账号、会话和行为指标,同时避免错误限流影响正常用户。

问:如何证明没有只修复演示接口?
答:检查项目里所有客户端 IP 的读取位置,包括认证中间件、日志、限流插件和风控 SDK。用相同的本地输入分别验证,确保它们遵循同一套可信代理策略。

九、清理与总结

完成后执行:

docker compose down

本实验没有创建持久化数据卷。如不再需要,可删除实验目录。

这次复盘的核心不是某条 curl 命令,而是一条可迁移的安全方法论:先画清请求经过哪些组件,再判断每个字段是谁写入的、谁能修改、下一层为何相信它,最后用可重复的对照实验验证边界。

对于代理头,最重要的结论是:请求数据不能自行升级为身份凭证。入口负责清洗,应用只信任受控代理,网络阻止绕过入口,而真正的权限判断必须依赖身份认证与授权。把这几层一起落实,才算完成加固。
 
顶部