客户端与授权服务器经浏览器传递 S256 挑战与回调授权码。另一条令牌端点路径用码加校验串换取令牌。客户端既可以是公共客户端,也可以是机密客户端。

OAuth 授权码用码换令牌

授权码流程把访问令牌的交付移到令牌端点,而不是在面向浏览器的授权重定向里直接返回访问令牌。这能减少令牌暴露在地址中的机会,却不代表浏览器绝不可能持有令牌。究竟由谁兑换授权码,以及结果保存在哪里,取决于客户端架构。审阅时应区分协议步骤、客户端类型和实际存储位置,不能把一种部署方式当成整个流程的定义。 授权跳转传递授权码,令牌端点交付令牌。PKCE、客户端与回调绑定、过期和单次使用各守一条边界。存储方式取决于客户端架构;离线模型不能证明完整登录流程。 公共客户端也能使用授权码流程 机密 Web 后端可以向令牌端点进行客户端认证,并把令牌保存在服务端。浏览器随后使用应用自己的会话,但会话安全仍需单独处理。原生应用和浏览器应用等公共客户端无法可靠地保守分发出去的客户端秘密,然而它们仍然可以使用授权码加 PKCE。因此,授权码流程并不是只有后端才能采用的方案。 若浏览器应用自行兑换,JavaScript 就会收到令牌响应。流程避免了在授权跳转地址里交付访问令牌,却不会消除脚本被入侵或令牌存储泄露的问题。后端架构改变了边界,并没有让所有浏览器侧风险消失。OAuth 解决委托访问;需要确认用户身份的应用,应采用 OpenID Connect 等适合的身份协议,并验证其中独立的身份凭据,不能把任意访问令牌直接当作登录证明。 把兑换绑定到原始请求 PKCE 为每次事务生成高熵校验串。授权请求携带它的 S256 挑战,令牌请求再提供原校验串,授权服务器检查二者是否对应。校验串不是固定的客户端口令,必须与发起它的事务关联。这样,只有截获授权码、却没有校验串的一方,不应能够完成兑换。日志和错误报告也不应同时泄露这两部分。 RFC 9700 要求公共客户端使用 PKCE,并建议机密客户端也使用。应选择 S256,避免降级成 plain。机密客户端仍需执行已配置的客户端认证,PKCE 不能代替这一职责。客户端认证也不一定意味着发送共享秘密;部署可以采用非对称认证机制。判断安全性时,要核对服务端实际执行的验证,而不是只看请求里出现了几个参数。 每枚授权码都应绑定客户端与回调地址,设置短有效期并限制只能使用一次。登记的回调地址通常必须精确匹配;原生应用的回环地址端口存在标准规定的例外。授权请求携带 redirect_uri 时,令牌请求中的值必须一致。在授权阶段就要拒绝未登记的回调,而不是等到授权码已泄露之后才靠兑换失败补救。 坏掉的样子 一次性并不能阻止攻击者抢先兑换一枚没有充分保护的授权码。过期只是缩短机会,不会让截获变得无害。回调中的授权码仍可能出现在访问日志、浏览器历史、来源信息和崩溃报告里。避免记录这些值,使用合适的 HTTPS 回调;开发原生客户端时,则按照原生回环重定向的专门规则处理,不要把例外扩展到任意明文回调。 ...

十月 20, 2027 · 2 分钟 · XB Field Notes
两个相同正文条有不同红色结束标记:CL 位于完整正文后,TE 位于零块段后并留下合成后续请求。

请求走私把代理链路带偏

请求走私发生在不同组件对 HTTP 消息边界理解不一致,而且攻击者利用了这种分歧的时候。在代理链中,被前端当作一个请求正文的字节,可能被后端当成另一个请求。关键证据是解析和转发结果不一致,而不只是抓包中出现两条 HTTP 状态行。理解问题时要把字节归属、连接复用和实际影响分开,不能直接用响应数量代替边界分析。 同一字节流,不相容的边界 HTTP/1.1 的组帧有明确规则。合法 Content-Length 可以指定正文长度;chunked 传输编码通过终止块和尾部字段结束。两种机制都没有的请求不包含正文。两种字段同时存在时,Transfer-Encoding 优先,但这是一种可疑组合,需要专门处理,而不是随意接受后继续复用连接。 RFC 9112 允许服务器拒绝同时携带两个字段的请求,或者仅按 Transfer-Encoding 处理;无论如何,响应后都必须关闭连接。中间组件若选择继续转发,必须先移除收到的 Content-Length,并在转发前处理 Transfer-Encoding。入口直接拒绝双重组帧是有用的严格策略,但不能把它说成标准描述的唯一合法行为。 CL.TE 表示前端使用 Content-Length、后端使用 Transfer-Encoding;TE.CL 则相反。其他分歧可能来自冲突长度,或对传输编码语法的不同处理。这些名称描述的是解析器实际行为,不代表报文有两种同样正确的解释。面对不合法输入,不能让每一跳各猜一次,再期待最终边界碰巧一致。 失步可以产生不同影响 完整的隐藏请求可能马上被后端处理,并不必然等待下一位受害者。另一些攻击留下不完整前缀,再与后续流量组合。响应队列污染只是可能后果之一;绕过路由过滤、缓存污染和未授权的后端请求,也可能在没有相同响应错配步骤时造成影响。实际后果取决于连接复用、路由、认证和应用行为,不能用一个实验情节概括全部攻击。 发现额外的后端请求时,应追溯具体字节和连接。仅靠数量不足,因为重试、健康检查、缓存和异步日志都可能让各层记录合理地不一致。后端出现没有对应边缘记录的请求,是调查线索,不是自动确证。应结合关联数据,并在可控实验中使用可辨认的合成请求,排除正常机制之后再判断边界是否被突破。 坏掉的样子 表面的请求头过滤可能漏掉协议转换时产生的歧义。HTTP/2 使用帧,不采用 HTTP/1.1 的 chunked 分界,但降级到 HTTP/1.1 又引入一次必须正确完成的序列化。对 HTTP/2 请求,Content-Length 必须与内容长度一致;无内容响应另有规则,因此不能说换成 HTTP/2 就不再存在任何相关长度检查。 端到端 HTTP/2 能避免部分降级特有的问题,却不是普遍免疫证明。应检查协议转换、统一拒绝畸形消息,并避免在不可恢复的组帧错误后继续复用连接。严格入口可以挡住某条具体攻击路径,即使另一组件仍然宽松;应按真实可达路径评估,不能断言只修一跳永远没有作用,也不能因为某一入口表现正确就忽略其他入口。 ...

十月 19, 2027 · 2 分钟 · XB Field Notes
紧凑 JWS 由头部、声明与签名或 MAC 三段组成。完整性保护并不加密声明。示意时间窗包含 nbf,不包含 exp;签发者与受众仍须检查。

JWT 带着签名与寿命

带签名的 JWT 携带一组声明,接收方可以检查它们的完整性,而不必先从会话表读取整枚令牌。便利并不等于保密,也不会自动完成授权或阻止重放。审阅时应先明确令牌格式和应用的验证契约,再分别测试边界。密码学验证成功,只是访问决策的一项输入,不能代替资源本身的权限判断。 签名保护完整性,并不提供保密性。紧凑 JWS 模型将密码学检查、应用策略与最终访问决策分开;时间窗不包含恰好到期的时刻。签名正确本身不能证明令牌适用。 带签名令牌只是 JWT 的一种形式 本文讨论以 JWS 表示的紧凑、带签名或消息认证码的 JWT:受保护头部、载荷、签名或认证码构成三个点分段。Base64url 可以逆向解码,并不提供保密性。但 JWT 也支持加密的 JWE 表示,因此“所有 JWT 都可读而且只有三段”并不准确。即使使用加密,也应减少个人信息,避免嵌入与当前用途无关的凭据。 HS256 用共享秘密计算 HMAC。每个持有秘密的验证方也能签发令牌,因此它不能证明究竟是哪一个持有者发出了令牌。非对称签名把签发与验证能力分开。无论采用哪种设计,都不能因为令牌附带了一把陌生密钥,就把它当作信任依据。可信签发者、密钥来源、允许算法和令牌种类需要一起配置。 验证是一份应用契约 实现可以先解析少量不可信数据,以定位已经配置的密钥,但在密码学验证及策略检查全部完成前,不能根据声明执行操作。把 kid 当作可信密钥集合内的标识,不允许它任意指定文件路径或网络下载地址。每把密钥必须绑定预期算法,不能让声称使用 HS256 的令牌把 RSA 公钥变成 HMAC 秘密。 ...

十月 18, 2027 · 2 分钟 · XB Field Notes
取值箭头进入 SQL 的 %s 占位符。独立紫色回环表示可选择复用;命名语句为零仍可保留绑定。原文实验,未重跑。

预备语句把取值与 SQL 结构分开

参数绑定为数据库中的取值提供一个明确位置。姓名里即使带有引号或分号,也仍然只是值,不会因此变成另一段查询条件。复用命名预备语句则是另一项优化:服务端能够在同一个会话内,跨多次执行保留语句信息。安全地绑定数据,并不要求应用为每一条查询都显式建立一个命名语句,也不要求先证明查询会频繁重复。 绑定保护 SQL 语法边界,却不会禁用取值内部仍需解释的语言。例如,绑定后的 LIKE 模式仍把百分号与下划线当成通配符。入口究竟接收模式,还是字面子串,需要另行约定,必要时按该运算符的规则转义。正则、全文检索和业务授权同样具有绑定之后的语义,不能因为成功参数化就认为输入已经符合全部产品约束。 坏掉的样子 在 Psycopg 中,占位符 %s 属于驱动接口,不是让程序使用 Python 字符串格式化。值应作为 execute 的第二个参数传入,占位符不要加引号,只有一个位置参数时也需要单元素元组。如果先把值插入字符串,再调用一个支持参数化的接口,原本希望保护的边界早就已经被越过;接口名称本身不会替程序补救这次拼接。 占位符代表值,不代表表名、列名或关键字。动态标识符需要明确的结构选择:先用白名单限制支持的列,再用驱动的标识符组合功能正确引用选中的列名。升降序关键字也应由应用控制。分页的数量与偏移量可以直接绑定,同时仍须按产品要求验证范围。能在语法上接收一个整数,不代表允许请求任意大的页面或无限制消耗资源。 预备语句不会把第一次的执行计划永久钉死。PostgreSQL 区分根据本次参数生成的定制计划,以及跨不同参数复用的通用计划;自动选择时会考虑规划开销与估算执行成本。模式或统计信息变化也可能触发重新规划。因此,语句保持参数化时,访问路径仍可以变化。是否复用某种计划,与输入能否变成 SQL 语法,是不同的性质,不能混为一谈。 命名语句为零时仍可绑定。命名复用和定制/通用计划是独立性质。原文实验未重跑;强制模式不说明延迟。 快速验证 准备安装了 Psycopg 3 的 Python 环境,把 PG_DSN 设置为可丢弃的 PostgreSQL 数据库连接。连接配置沿用已有安全提供方式。实测版本为 Psycopg 3.3.5 与 PostgreSQL 18.6。将下面代码保存为 Python 文件,在已设置该环境变量的终端执行。使用的是临时表,连接关闭后会自动消失: ...

十月 16, 2027 · 2 分钟 · XB Field Notes
可执行文件属主经 exec 条件检查影响有效与保存 UID,真实 UID 保持不变。

Setuid 在 exec 时改变有效身份

Linux 可执行文件上的 setuid 位,可以在 exec 成功时把进程的有效用户 ID 改为文件属主的 ID。它不会把进程全部凭据都替换成属主的登录身份。特权辅助程序因此能够代表另一个账户完成受控操作,但输入处理和特权持续时间都会成为安全边界的一部分。模式位本身既不能证明身份转换已经发生,也不能证明这个辅助程序安全。 文件位与进程凭据分开观察 通常的 set-user-ID 转换会改变有效 UID,再将结果复制到保存的 set-user-ID;真实 UID 保持不变。组凭据是另一套状态:setuid 不会自动装入文件属主的附加组。Linux 还存在文件系统身份和能力集合,因此真实、有效与保存 UID 是有用的观察,却不是权限的完整描述。不能看到有效身份变成某个账户,就假设已经拥有该账户登录会话的全部上下文。 应检查实际部署的文件,不要推断整个发行版都采用同一权限。用于修改密码的特权程序,在容器或定制镜像里可能有不同安装方式。属主执行位位置的小写 s 表示同时具备 setuid 和属主执行位,大写 S 则表示存在 setuid 但没有那个执行位。两种显示都不能替代属主核验、目标文件核验,以及对真实启动上下文的检查。 执行环境可以抑制转换 Linux 文档列出了 no_new_privs、nosuid 挂载和被跟踪等条件,它们可能抑制特权转换;某些文档记录的组合也可能直接让执行失败。文件能力同样受到执行约束。不能为了让演示成功,就关闭这些保护。应确定目标路径与调用进程实际适用哪些政策,如实说明限制,而不是把受保护环境当作必须修复的故障。 Linux 忽略解释器脚本上的 set-user-ID 与 set-group-ID 位。因此,把原生辅助程序改成脚本并保留模式,不会保留原先的提权行为。给通用解释器增加特权也不是安全替代方案。特权程序另外启动的可执行文件,还可能主动重置凭据,所以运行独立的 id 命令,不一定忠实显示原辅助程序内部的身份。观测点必须与要验证的进程一致。 ...

十月 2, 2027 · 2 分钟 · XB Field Notes
先选名称后打开之间存在竞争空隙,排他原子创建则返回已拥有文件的描述符。

在可信位置原子创建临时文件

共享临时目录是其他用户也可能写入的命名空间,并不意味着里面每个文件都公开可读;内容访问仍受文件权限约束。真正危险的假设,是以为选定一个路径,就已经拥有这个名字对应的对象。应原子地创建新对象,并继续使用返回的句柄,而不是信任一个可预测名字,或先检查它不存在、稍后再打开。 名字归属与内容访问是两回事 Linux 的 /tmp 常用模式 1777,即宽松的目录写入、搜索权限,加上 sticky 位。sticky 限制其他用户删除或重命名目录项,却不让现有文件自动保密,也不提前保留尚未创建的名字。文件属主、目录属主、能力及其他访问控制仍然重要。应检查实际环境,不能假定所有操作系统都采用同一路径、挂载方式和权限。 普通写入打开可能跟随预先存在的符号链接。O_CREAT|O_EXCL 拒绝已经存在的最后一个路径名称,包括符号链接。O_NOFOLLOW 则拒绝最后一段是符号链接的情况,但单独使用时不会拒绝既有普通文件,也不保护所有祖先目录。这些是不同契约。应使用可信父目录和适当的临时文件接口,不能把一个标志当成通用路径安全方案。 创建模式也只在真正新建文件时生效。以 O_CREAT 和模式 0600 打开已有文件,不会收紧它原先的权限。随机名字降低碰撞概率,但安全性质来自排他创建,而不只是字符串看起来难以猜测。只生成名字的接口仍留下一段空隙,其他进程可以在真正创建之前占用该名字,因此先检查再创建也不能消除竞争窗口。 位置设置不等于隔离 Python 的 tempfile.mkstemp 安全地创建并打开文件,调用方负责关闭和删除。TemporaryDirectory 提供受限目录和正常退出时的清理。TMPDIR 影响位置选择,却不会自己创建私有目录,也不验证隔离策略。Python 会缓存默认目录选择;如果操作必须使用某个位置,应明确传入 dir=,不要依赖运行中修改环境变量来重新选择。 systemd 的 PrivateTmp= 可以给服务单独的临时文件命名空间,但它改变共享行为,并有平台与 unit 条件,不等同于设置一个环境变量。服务分别使用独立命名空间时,不能依赖跨服务的 /tmp 路径通信。下方本地实验不会启用或验证 systemd 隔离,也不能据此宣称部署已经具有相同隔离边界。 tmpfs 随使用量分配空间,在可用且被允许时可以使用交换空间。没有指定大小时,默认容量上限是物理内存的一半;这是上限,不是挂载时预留的内存。数据分配额度和 inode 数量都可能分别阻止后续工作。/tmp 也不一定挂在 tmpfs 上,因此容量监控应依据实际挂载与负载,不能把目录名字直接当成存储类型。 ...

九月 26, 2027 · 2 分钟 · XB Field Notes
父页面包含子响应。嵌入许可箭头从子响应的 frame-ancestors 策略指向获准的父来源。

框架策略明确谁可以嵌入当前页面

点击劫持利用用户对界面的误判,让一个动作落到并非其真实意图的控件上。恶意父页面可以把被嵌入的应用放在误导性视觉层后面,使用户点击实际进入该应用中的真实按钮。父页面不必能够读取子页面的文档结构,用户仍然可以与框架里的内容交互。因此,同源读取限制不能替代应用是否允许被嵌入的独立决定,也不能仅靠看不到对方文档就判断点击过程安全。 被嵌入的响应可以通过内容安全策略禁止任何祖先嵌入,或者明确列出允许的祖先来源。这里写的应当是获准包含当前文档的页面来源。假设商户页面嵌入支付页面,应由支付页面的策略允许商户来源;仅列出支付组件自己的来源,并不能表达商户有权嵌入它。配置之前先明确哪一份响应属于子页面、哪个来源是父页面,可以避免把允许列表方向写反。 把限制放到正确的响应上 嵌入祖先指令回答谁能嵌入当前文档,框架来源指令则回答当前文档自己可以加载哪些框架。给子页面设置严格的 frame-src,并不会阻止另一个父页面嵌入它。默认来源全部拒绝,也不会作为 frame-ancestors 的回退值。这些指令控制的是关系中的不同方向,不能仅因为名字都涉及框架,就把其中一个当成另一个的替代品。 嵌入祖先指令不能通过 CSP 的 meta 元素生效,必须通过响应头交付。遇到多层嵌套时,每一级祖先都需要符合策略,而不只是直接父级。某个组件即使可以合法放在指定应用中,当该应用本身又被放进未经许可的外层框架时,仍可能被阻止。因此制定允许列表时,要审阅整个预期嵌入链,不能只凭最内层组件和它紧邻的容器作判断。 坏掉的样子 一次部署可能只给容器页面增加了策略,却让真正敏感的子页面响应保持不受限制;另一次部署可能把策略写入 HTML 元数据,看到配置文字存在就误以为浏览器会执行。应检查实际被框架加载的响应,并验证浏览器是否渲染。仅收到二百状态,既不能证明嵌入成功,也不能证明没有框架防护,因为浏览器可以收到文档后,拒绝在当前祖先环境中显示它。 互相冲突的响应头也可能误导审阅者。在支持这套规则的现代浏览器里,强制执行的嵌入祖先策略优先于该响应的 X-Frame-Options。给允许某个父来源的策略再附加一个 DENY,并不必然得到期望中的更严格结果。如果确实需要保留多个响应头,应让它们与实际受众和浏览器支持约定一致,不能假定它们都像多份 CSP 那样自然取限制交集。 快速验证 将代码保存为 frame-policy.py,执行 python3 frame-policy.py --serve,并打开打印出的父页面地址。本次实际使用 Python 3.14.4 与 Chrome 152。父服务和子服务位于不同的本机回环端口,六份子响应只有合成说明文字,以及表示成功渲染的消息。父页面只接受预期子来源发来的消息并展示结果,结束后按控制键加 C 关闭两个临时服务,不会操作真实账号或业务控件。 import json import sys from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Event, Thread from urllib.request import urlopen paths = ["open", "csp-block", "xfo-block", "csp-allow", "meta", "frame-src"] requests = [] class Child(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): name = self.path.lstrip("/") assert name in paths requests.append(name) meta = ('<meta http-equiv="Content-Security-Policy" content="frame-ancestors \'none\'">' if name == "meta" else "") body = ("<!doctype html><meta charset=utf-8>" + meta + "<p>Rendered: " + name + "</p>" "<script>parent.postMessage({frame:" + json.dumps(name) + "}," + json.dumps(app_origin) + ");</script>").encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") if name == "csp-block": self.send_header("Content-Security-Policy", "frame-ancestors 'none'") elif name == "xfo-block": self.send_header("X-Frame-Options", "DENY") elif name == "csp-allow": self.send_header("Content-Security-Policy", "frame-ancestors " + app_origin) self.send_header("X-Frame-Options", "DENY") elif name == "frame-src": self.send_header("Content-Security-Policy", "frame-src 'none'") self.end_headers() self.wfile.write(body) class Parent(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): if self.path == "/events": body = json.dumps(requests).encode() kind = "application/json" else: body = ("""<!doctype html><meta charset="utf-8"><title>Frame policy lab</title> <h1>Which child responses can this parent embed?</h1><pre id="result">Waiting for frame messages...</pre> <button id="check">Show server requests</button><div id="frames"></div><script> const child=""" + json.dumps(child_origin) + ";const names=" + json.dumps(paths) + """; window.received=[]; window.snapshot=async()=>{ const result={rendered:[...window.received].sort(),serverRequests:await (await fetch('/events')).json()}; document.getElementById('result').textContent=JSON.stringify(result,null,2);return result; }; addEventListener('message',event=>{ if(event.origin===child && names.includes(event.data?.frame)) { window.received.push(event.data.frame); document.getElementById('result').textContent=JSON.stringify(window.received,null,2); } }); document.getElementById('check').onclick=()=>window.snapshot(); for(const name of names) { const frame=document.createElement('iframe');frame.src=child+'/'+name;frame.title=name; frame.width=240;frame.height=100;document.getElementById('frames').append(frame); } </script>""").encode() kind = "text/html; charset=utf-8" self.send_response(200) self.send_header("Content-Type", kind) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) child = HTTPServer(("127.0.0.1", 0), Child) app = HTTPServer(("127.0.0.1", 0), Parent) child_origin = f"http://127.0.0.1:{child.server_port}" app_origin = f"http://127.0.0.1:{app.server_port}" threads = [Thread(target=server.serve_forever, daemon=True) for server in (child, app)] for thread in threads: thread.start() try: for name in paths: with urlopen(child_origin + "/" + name, timeout=5) as response: assert response.status == 200 if name == "csp-allow": assert response.headers["Content-Security-Policy"] == "frame-ancestors " + app_origin assert response.headers["X-Frame-Options"] == "DENY" response.read() requests.clear() print(json.dumps({"app_url": app_origin, "child_origin": child_origin, "wire_responses": 6}), flush=True) if "--serve" in sys.argv: Event().wait() except KeyboardInterrupt: pass finally: for server in (child, app): server.shutdown() server.server_close() for thread in threads: thread.join(timeout=5) 开放子页面应正常渲染;csp-block 被禁止祖先嵌入策略拒绝,xfo-block 被 DENY 拒绝。csp-allow 虽然同时发送 DENY,但强制祖先策略明确允许当前父来源,因此正常渲染。meta 页面也会渲染,因为该交付方式不能执行嵌入祖先指令;frame-src 页面同样渲染,因为它限制的是子页面接下来自己加载的框架,而不是正在验证的父子嵌入关系。 ...

九月 23, 2027 · 2 分钟 · XB Field Notes
一个面板展示文档到资源的有条件外部引用路径;独立面板展示文档内有限的分支展开。

XXE 借解析器的权限跟随外部引用

XML 外部实体处理可能让解析器按照文档提供的引用,使用应用自身的权限获取资源。具体解析器及配置不同,结果可能是读取文件,也可能是向其他服务发起请求。支持 XML 不等于必然存在漏洞;需要先识别真正使用的库、已启用功能、资源解析器和后续处理步骤,才能判断输入究竟能够触发什么操作。 外部访问与内部展开不是一回事 外部解析实体可以用系统标识符指向资源。应用启用解析、进程又有能力访问时,读取文档便可能加载它。把内容反射到响应中只是泄露的一种形式;即使响应没有带回资源,向外发出的请求本身仍可能产生影响。访问范围同时受到解析策略、文件权限、网络控制和目标服务认证的限制,并不是一个 URL 就能保证拿到任意内部秘密。 内部实体则可以完全在文档中展开,不需要文件或网络。在保护不足的解析器中,重复嵌套替换可能消耗大量资源,这与 XXE 是不同问题。一个有明确上限的一千字符示例,只证明发生了替换,不能证明现代解析器允许无限放大。实体递归引用自身不符合 XML 规则,受支持的实现还可能在内存耗尽之前就拒绝过度放大,不能把所有解析器都描述成必然陷入死循环。 在解析开始前确定政策 输入格式不需要 DTD 时,应使用解析器支持的策略或回调拒绝文档类型声明。同时按具体实现关闭不需要的外部普通实体、参数实体和外部子集。库与版本的默认行为不同,仅禁止网络访问可能仍允许读取本地文件;只做 schema 校验,也可能已经晚于危险的初次解析。应检查实际入口怎样构建解析器,而不是在配置文件中找到一个相似名字就认为已经完成防护。 确实需要外部资源时,只把明确批准的标识映射到受控资源。schema 导入、XInclude 与转换器要单独审阅,XXE 开关未必控制这些加载器。还要限制字节数、嵌套深度与资源消耗,并维护解析库版本。一个短包装函数不可能自动抵御全部畸形输入、压缩炸弹或后续处理风险,政策边界应如实写明。 坏掉的样子 只在原始字节中搜索 ASCII 的 DOCTYPE 拼写,可能漏掉 UTF-16 之类编码后的声明;简单文本搜索又会误杀包含该字样的普通注释。应让理解编码和语法的解析器识别结构,再在声明边界执行政策。成功拒绝一条熟悉攻击字符串,不等于覆盖了入口接受的所有编码,也不等于找到了所有可能读取资源的路径。 SVG 上传可能进入服务器端 XML 解析器或图像转换器。该环节的配置决定是否加载外部引用,不能因为文件是 SVG,就声称每种浏览器都会自动解析外部实体。应检查预览、导入以及后续重解析的每一环;第一个解析器安全,不会替第二个开启不同加载器的组件提供保护。测试对象应当是实际管线,而不是只看文件扩展名。 启用断言的原样 Expat 夹具支持有界解析行为,不证明外部资源访问、全部处理器与编码,也不保证通用安全。 快速验证 保存为 xxe_probe.py,使用受维护的 Python 3 执行 python3 xxe_probe.py。须启用断言,不使用 -O 或 PYTHONOPTIMIZE:优化模式会跳过 assert 内的参数实体设置调用和正例检查。实验采用 Expat 回调、固定内存字节与小规模限制,不配置打开文件或发起网络请求的资源加载回调。拒绝声明中的路径与域名只是测试输入,不会访问对应资源,也不会读取真实系统文件。 ...

九月 22, 2027 · 2 分钟 · XB Field Notes