XPath 注入防御实战:参数化查询配置指南

适用场景

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 过滤作为兜底。按此顺序整改,登录绕过与盲注提取两条攻击路径可同时封堵,回归测试覆盖恒真、注释截断、函数滥用三类载荷即可确认防护生效。