适用场景
XPath 注入防御是 XML 数据查询场景下必须落实的应用安全措施。当应用使用 XPath 表达式从 XML 文档中检索用户、订单、配置或权限数据时,只要表达式由字符串拼接而成,攻击者就能构造特殊输入改写查询逻辑,绕过登录校验或遍历全部节点,后果与 SQL 注入同级。典型触发场景包括:
- 登录校验使用
/users/user[username='x' and password='y']形式的 XPath 查询; - SOAP 接口、SAML 断言解析、RSS/OPML 导入、SVG/DOCX 等基于 XML 的文档处理流程;
- 老式 CMS、报表系统、配置中心把业务数据存放在 XML 而非数据库中;
- Python 的
lxml、Java 的 JAXP、PHP 的DOMXPath、Node.js 的xpath库被直接传入用户输入。
前置条件
- 可修改应用代码,并具备测试环境用于复现与回归;
- 可访问 XML 数据源(文件、数据库字段或接口返回);
- 服务器可部署 ModSecurity/Nginx 规则作为兜底拦截;
- 已知应用调用的 XPath 引擎类型:JAXP、libxml2(lxml/PHP)、或其他实现。
原理说明
XPath 是一门用于在 XML 文档中定位节点的查询语言。它的逻辑运算符、字符串函数与 SQL 高度相似,但缺少统一参数化标准,导致开发者习惯性地用字符串拼接构造表达式。
假设后端逻辑如下:
String expr = "/users/user[username='" + user + "' and password='" + pass + "']";
当 user 被提交为 ' or '1'='1 时,表达式变形为:
/users/user[username='' or '1'='1' and password='任意值']
由于 '1'='1' 恒为真,只要文档中存在任意 user 节点,查询即返回结果,口令校验被完全绕过。更进一步,攻击者可以利用 XPath 字符串函数构造盲注:
count(//user)—— 判断节点总数,配合条件表达式做布尔盲注;substring(string(//user[1]/password),1,1)—— 逐字符提取字段内容;string-length()—— 推断字段长度;doc()/collection()(XPath 2.0+)—— 部分实现允许读取外部资源,风险进一步扩大。
因此,XPath 注入防御的核心有两点:输入不进入表达式结构本身,以及解析器关闭外部实体与外部函数能力。
与 SQL 注入相比,XPath 注入有两个容易被低估的特点:一是数据源多为静态文件或接口返回值,运维侧通常不做访问审计,数据被遍历导出后难以察觉;二是多数 XPath 引擎没有预编译语句的概念,开发者习惯把参数直接写进表达式文本,缺少「参数化」这一默认安全选项。因此整改时必须显式调用引擎提供的变量机制,不能依赖语言本身的安全默认值来兜住问题。
操作步骤
步骤一:定位危险拼接点
在代码库中检索 XPath 关键特征,优先排查字符串拼接:
grep -rnE "(compile|evaluate|DOMXPath|xpath|XPathExpression)\s*\(" ./src --include=*.java --include=*.php --include=*.py --include=*.js | grep -E "\\+|concat|\\.format|f\"|\$\{"
命中结果中,凡是表达式字符串里出现变量插值的行,全部标记为待整改点。
步骤二:Java(JAXP)改用变量绑定
JAXP 原生支持 XPath 变量,绑定后的值只作为数据参与比较,不会改变表达式结构:
import javax.xml.namespace.QName;
import javax.xml.xpath.*;
import org.w3c.dom.*;
XPathFactory factory = XPathFactory.newInstance();
XPath xpath = factory.newXPath();
final String u = username;
final String p = password;
xpath.setXPathVariableResolver(new XPathVariableResolver() {
public Object resolveVariable(QName name) {
if ("u".equals(name.getLocalPart())) return u;
if ("p".equals(name.getLocalPart())) return p;
return null;
}
});
XPathExpression expr = xpath.compile("/users/user[username=$u and password=$p]");
NodeList nodes = (NodeList) expr.evaluate(doc, XPathConstants.NODESET);
boolean ok = nodes.getLength() == 1;
同时关闭外部实体与 DTD 处理,避免 XPath 之外经由 XML 解析引入的 XXE 风险:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
步骤三:Python(lxml)使用 XPath 变量
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True, dtd_validation=False)
tree = etree.parse("users.xml", parser)
nodes = tree.xpath("/users/user[username=$u and password=$p]", u=username, p=password)
ok = len(nodes) == 1
若确需动态节点名,只用白名单映射,禁止直接拼接:
FIELD = {"username": "username", "email": "email", "phone": "phone"}
field = FIELD.get(request.args.get("field"), "username")
nodes = tree.xpath("/users/user[%s=$v]" % field, v=value)
步骤四:PHP(DOMXPath)以白名单替代拼接
PHP 的 DOMXPath::evaluate() 不支持变量绑定,必须用白名单或强类型过滤隔离输入:
$allow = ['alice', 'bob', 'carol'];
$user = $_POST['user'] ?? '';
if (!in_array($user, $allow, true)) {
http_response_code(400);
exit('invalid user');
}
$xp = new DOMXPath($doc);
$nodes = $xp->query("/users/user[username='$user']");
更通用的做法是统一做格式校验,只接受 ^[A-Za-z0-9_.-]{1,32}$ 形式的输入,出现单引号、括号、斜杠、冒号一律拒绝:
$pattern = '/^[A-Za-z0-9_.\-]{1,32}$/';
if (!preg_match($pattern, $user)) {
http_response_code(400);
exit('bad parameter');
}
步骤五:ModSecurity 兜底拦截
在 WAF 层拦截带 XPath 函数与恒真条件的请求,为代码整改留出缓冲:
SecRule ARGS "@rx (?i)(string-length|substring|normalize-space|system-property|doc|collection)\s*\(" \
"id:9001001,phase:2,deny,status:403,log,msg:'XPath Function Abuse',tag:'xpath-injection'"
SecRule ARGS "@rx (?i)['\x22)]\s*(or|and)\s*\(?\s*['\x22]?\w+['\x22]?\s*=\s*['\x22]?\w+['\x22]?" \
"id:9001002,phase:2,deny,status:403,log,msg:'XPath Tautology Injection',tag:'xpath-injection'"
SecRule REQUEST_URI|ARGS_NAMES "@rx (?i)://" \
"id:9001003,phase:2,deny,status:403,log,msg:'XPath Absolute Path Probe',tag:'xpath-injection'"
步骤六:Nginx 前置过滤
location /api/ {
if ($args ~* "(string-length|substring|system-property|count)\s*\(") {
return 403;
}
if ($args ~* "(%27|\x27)\s*(or|and)\s*(%271%27|\x271\x27)\s*=") {
return 403;
}
proxy_pass http://127.0.0.1:8080;
}
步骤七:应用入口统一过滤
历史代码一时无法逐处改造时,可在应用入口加入过滤器,把 XPath 元字符与函数特征列入拦截范围,并记录请求来源,作为整改期间的过渡手段:
public class XPathGuardFilter implements Filter {
private static final Pattern DANGER = Pattern.compile(
"(?i)('|\")\\s*(or|and)\\s*|\\b(string-length|substring|count|doc|collection|system-property)\\s*\\(");
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
for (Object v : req.getParameterMap().values()) {
for (String val : (String[]) v) {
if (val != null && DANGER.matcher(val).find()) {
log.warn("xpath-injection blocked ip={} value={}", req.getRemoteAddr(), val);
((HttpServletResponse) response).sendError(400, "invalid parameter");
return;
}
}
}
chain.doFilter(request, response);
}
}
过滤器属于兜底层,不能替代步骤二至步骤四的变量绑定。上线后应统计拦截日志,按真实业务参数形态持续收敛正则,避免长期误拦正常请求。
配置验证
整改后用以下载荷做回归测试,正确结果应全部返回业务错误或 403,而不是成功登录或全量数据:
# 恒真绕过
curl -s -X POST https://www.example.com/api/login \
-d "user=' or '1'='1&pass=x"
# 注释截断(部分引擎支持)
curl -s -X POST https://www.example.com/api/login \
-d "user=admin' or 1=1 or '&pass=x"
# 布尔盲注探测
curl -s "https://www.example.com/api/query?field=username&value=' or count(//user)%3E0 or '"
# 函数滥用探测
curl -s "https://www.example.com/api/query?value=substring(password,1,1)"
同时核对服务端日志,确认 ModSecurity 命中 9001001、9001002 规则号:
grep -E "900100[123]" /var/log/modsec_audit.log | tail -20
常见问题
Q1:已经做了输入转义,是否还需要变量绑定?
需要。不同 XPath 引擎的词法规则不一致,单引号转义、反斜杠转义在 libxml2、JAXP、Saxon 之间并不通用,靠转义极易漏配。变量绑定/白名单是引擎层面的结构隔离,属确定性方案,转义只能作为额外一层。
Q2:XPath 1.0 没有 doc() 函数,是否就没有文件读取风险?
不能这样判断。风险主要来自 XML 解析器本身:只要允许 DTD 与外部实体,攻击者可通过 XML 输入触发 XXE,读取本地文件或发起内网探测。因此必须在 DocumentBuilderFactory 或 XMLParser 层显式关闭外部实体与网络访问,与 XPath 版本无关。
Q3:WAF 规则上线后误拦正常请求怎么办?
先观察 24 小时命中日志,把业务侧合法的括号、函数名请求(如报表参数)加入白名单 URI 或参数名维度豁免,再收紧为阻断。规则 ID 建议固定分段(90010xx)便于运维区分,不要与 CRS 默认 9xxxxx 段混淆。
Q4:使用框架自带的 XML 查询封装,是否还需要关注 XPath 注入?
需要。多数框架的 XPath 封装只提供节点遍历与序列化,不对表达式文本做参数化处理,最终仍拼成字符串交给底层引擎执行。评估时确认三点:框架是否暴露变量绑定接口、底层是否允许 DTD 与外部实体、表达式是否由用户输入参与构造。三项中任意一项不满足,就应视为存在注入面。
总结
XPath 注入防御的落地顺序是:先定位字符串拼接的表达式构造点,再用引擎原生变量绑定替换拼接(JAXP 的 XPathVariableResolver、lxml 的 xpath(..., **vars)),无法参数化的实现(DOMXPath)改用白名单与正则约束;同时在 XML 解析层关闭外部实体,最后叠加 ModSecurity 与 Nginx 过滤作为兜底。按此顺序整改,登录绕过与盲注提取两条攻击路径可同时封堵,回归测试覆盖恒真、注释截断、函数滥用三类载荷即可确认防护生效。