CDN服务在加速静态资源分发时,会把源站文件缓存在边缘节点。用户访问命中缓存时,节点直接返回本地文件,不再回源。这个过程的背后包含多次对缓存状态的检查和文件读写操作。检查与使用之间的时间差,正是TOCTOU(time-of-check to time-of-use)竞争条件的落脚点。攻击者如果在缓存文件被判定有效之后、实际读取之前完成替换,就可能让边缘节点向所有用户返回被篡改的内容。

一、TOCTOU在CDN文件缓存中的形成机制
边缘节点收到用户请求后,会先根据URL、Host、请求头等要素计算缓存键,再到本地缓存目录中查找对应文件。如果缓存未命中或已过期,节点会向源站发起回源请求,源站返回文件内容和响应头,节点将内容写入磁盘,更新缓存元数据,然后返回响应。这个流程在单请求视角下是线性的,但在真实环境中,边缘节点会同时处理大量请求。多个请求可能同时算出一个缓存键未命中,各自触发回源,最后并发写入同一个文件。
TOCTOU的经典定义是程序先检查某个条件,再根据该条件执行操作,但条件在检查和操作之间可能发生变化。在CDN文件缓存里,可被检查的对象包括缓存文件是否存在、修改时间是否在有效期内、文件哈希是否与记录一致、目录路径是否指向预期位置。可被使用的对象则是后续的文件读取、写入、重命名或删除动作。攻击者如果能影响这些检查所依赖的文件系统状态,就有机会让检查结果失效。
下面这段简化代码展示了一个典型的不安全流程:
def handle_request(key):
if not cache.exists(key) or cache.is_expired(key):
resp = fetch_origin(key)
cache.write(key, resp.body)
return cache.read(key)
这段逻辑里,cache.exists与cache.write之间存在时间窗。并发请求可能同时进入未命中分支,产生多次回源和多次写入。文件缓存最终保留哪个响应,取决于谁最后完成写入。攻击者可以利用这一点,通过并发请求提高恶意响应被缓存的概率。
二、文件缓存中的典型攻击路径
通常情况下,CDN节点在回源更新大文件时,会先把响应写入临时文件,再通过重命名或移动操作发布为正式缓存。临时文件的命名规则如果可预测,或者缓存进程与其他进程共享可写目录,就会产生符号链接攻击面。攻击者监控临时文件创建后,可以抢在节点验证或移动之前删除临时文件,并用符号链接指向攻击者希望缓存的文件。当节点继续执行移动操作时,可能把非预期内容放入缓存,或者覆盖关键文件。
下面是一段攻击者可能使用的监控与替换脚本:
inotifywait -m -e create /tmp/cache-tmp/ | while read path action file; do
if [[ "$file" == cache-* ]]; then
rm -f "/tmp/cache-tmp/$file"
ln -s /var/www/html/evil.js "/tmp/cache-tmp/$file"
fi
done
这种攻击需要攻击者具备对缓存临时目录的写权限,常见于共享主机、容器逃逸后的本地访问或配置不当的缓存代理。即便没有直接写权限,攻击者还可以通过路径归一化差异影响缓存键与文件系统路径的映射。例如URL编码、大小写变化或多余的点段,可能让节点在检查阶段认为请求的是安全路径,而实际写入阶段被文件系统解析到其他目录。这种路径混淆与TOCTOU结合后,会扩大可被替换的文件范围。
三、利用并发请求放大竞争窗口
TOCTOU窗口通常只有几毫秒到几十毫秒,单纯等待很难命中。攻击者通常使用并发请求工具,从多个连接同时请求同一个缓存键,制造大量未命中回源和写入竞争。并发度越高,在检查与写入之间插入修改操作的机会就越大。对于目标URL,攻击者可以清除或绕过本地缓存头,强制节点进入回源流程。
下面的脚本演示如何用多线程对同一个CDN资源发起并发请求:
import threading
import requests
url = "https://cdn.ipipp.com/assets/app.js"
def burst():
for _ in range(20):
try:
requests.get(url, headers={"Cache-Control": "no-cache"})
except Exception:
pass
threads = [threading.Thread(target=burst) for _ in range(30)]
for t in threads:
t.start()
for t in threads:
t.join()
仅有并发请求还不够,攻击者通常需要配合源站可控或存在差异化的回源响应。例如攻击者通过特定请求头让源站返回一份恶意JavaScript,同时用另一个正常请求触发缓存写入。如果节点在合并回源请求时存在缺陷,或者多个回源请求分别返回不同内容,恶意版本可能最终覆盖正式缓存。这类攻击还可能结合缓存欺骗漏洞,让私有内容进入公共缓存。
四、防御与修复:从原子操作到访问隔离
防御TOCTOU的核心思路是消除检查与使用之间的时间窗。对文件缓存来说,最直接的修复是使用原子写入:先把内容写入不可预测的临时文件,再通过os.replace或rename系统调用一次性替换正式文件。这样任何观察者只会在替换前看到旧文件,在替换后看到新文件,不会看到写了一半的状态。同时,更新流程应该用文件锁串行化对同一缓存键的写入,避免多个回源任务同时修改同一路径。
下面是一段具备基本原子写入与哈希校验的更新逻辑:
import os
import tempfile
import hashlib
def safe_update_cache(cache_dir, key, body, expected_etag):
digest = hashlib.sha256(body).hexdigest()
if expected_etag and digest != expected_etag:
raise ValueError("content hash mismatch")
fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=cache_dir)
with os.fdopen(fd, "wb") as f:
f.write(body)
f.flush()
os.fsync(f.fileno())
final_path = os.path.join(cache_dir, key)
os.replace(tmp_path, final_path)
除了代码层面的原子操作,部署环境也需要做访问隔离。缓存目录不应该暴露给不可信进程,临时文件应使用0600权限,并禁用符号链接跟随。CDN供应商通常还会实现单飞机制,即同一个缓存键只允许一个回源请求,其他请求等待结果,这能有效减少并发写入竞争。源站侧应正确设置缓存控制头,避免敏感内容被缓存。运维人员可以监控异常回源数量、缓存目录中的符号链接以及临时文件堆积,这些指标能帮助发现正在进行的竞态攻击。

