1. What
竞争窗口,顾名思义就是web应用程序处理某一个请求时会有一个短暂的子状态转换,比如说首先查询数据库,然后做一个check,然后再更新数据库,这一系列的子状态转换就会出现竞争窗口:
当用户通过某种手段将两个请求同时抵达到服务端的应用程序,应用程序的两个线程会同时对数据库进行查询,进而都发现满足check条件,然后就会返回成功,然后再去更新数据库,因此此时现象是两个请求都被服务端认为满足条件了。但为什么这种攻击不好触发呢,常常因为这么几个原因:
- 竞争窗口太短了,一般也就几毫秒,甚至更短
- 数据包既要能同时到达服务端,然后服务端还要同时处理,这两个延迟,一个是网络延迟一个是服务端内部延迟,都会导致竞争窗口稍纵即逝,如下图很难对齐:
2. 一些场景
假设有这么个技术可以实现两个请求同时让服务端处理,以达到竞争窗口的出现,那么我们可以干什么坏事呢?
- 多次使用优惠券:多次兑换的请求,服务端都被认为是当前优惠券没有用过,然后这样我们就可以以一个极低的价格买到一个商品
- 绕过反暴力破解速率限制:一般网站都有防暴力破解的措施,比如说3次输错就要锁定你的账户,但由于竞争窗口的出现,我们同时测试100个弱口令,然后服务端认为当前输错次数还都是0次,这样我们就绕过了防暴力破解的机制
- 一些用时间戳作为token生成的算法:如果两次请求同时达到,那么算出的token是一样的,那么我就可以用第一个请求的token值登入到第二个请求用户的管理界面中
- 积分商城情况购物车:一般购买的流程,都是将购物车礼品进行总和叠加,然后与你的积分进行比对,如果商品总价值小于你的价值,接着就会进行实际下单和积分扣取,订单验证和确认之间可能存在竞争窗口。这可以让我们在服务器检查是否有足够的商店积分后再向购物车添加更多商品。也就是说,我们在后面发送向购物车添加商品的请求,也会被实际下单。
3. How
3.1. 背景技术
使多个请求同时被服务端处理的这项技术真实存在,由portswigger研究总监白帽黑客james kettle发明,他反复发送了一批 20 个请求,从 Melbourne 到 Dublin 17000 公里,并测量了每个批次中第一个和最后一个请求的执行开始时间戳之间的差距,中位数差不多是1ms,标准差是0.3ms,现在我简单介绍一下我对此技术的理解,james kettle镇楼:
这里面涉及两个概念,一个是单包多请求技术,一个是最后一个字节同步技术:
- http2新特性,我们可以将两个完整的http/2请求放入一个tcp数据包中:
- http1.1中我们可以在发送请求时,保留一小片段,此时服务端认为请求还没有发完,就不会处理,然后随时发送最后一小段内容,以达到控制服务端什么时候处理的目的。
依托两大技术,我们具体做法如下:
首先,预先发送每个请求的大部分内容:
- 如果请求没有正文,则发送所有标头,但不要设置 END_STREAM 标志。保留设置了 END_STREAM 的空数据框。
- 如果请求有正文,则发送标头和除最后一个字节之外的所有正文数据。 保留包含最后一个字节的数据帧。
您可能很想发送完整的正文,并依赖于不发送END_STREAM,但在某些使用 content-length 标头来决定消息何时完成,而不是等待END_STREAM的 HTTP/2 服务器实现上,这将中断。
接下来,准备发送最终帧:
- 等待 100 毫秒以确保初始帧已发送。
- 确保禁用 TCP_NODELAY - Nagle 的算法对最终帧进行批处理至关重要。
- 发送 ping 数据包以预热本地连接。如果不这样做,OS 网络堆栈会将第一个最终帧放在单独的数据包中。
最后,发送保留的帧。您应该能够使用 Wireshark 验证它们是否位于单个数据包中。
本质就是,最后一个字节同步+最后内容多请求放在一个报文中发送
3.2. 脚本小子出场
有了圣剑你也可以成为最厉害的骑士,这个现成的技术可以使用工具轻松实现,一共两个办法:
- 一个是burp suite的基于group发送包,这里选择并行发送(single packet attack):
- 一个burp 的extend app安装Turbo Intruder ,里面有一个race攻击的py脚本
3.3. 源码分析
我们看看封装的脚本背后是如何实现的,本质是调用了3个函数:
ini
# if the target supports HTTP/2, use engine=Engine.BURP2 to trigger the single-packet attack
# if they only support HTTP/1, use Engine.THREADED or Engine.BURP instead
# for more information, check out https://portswigger.net/research/smashing-the-state-machine
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2
)
# the 'gate' argument withholds part of each request until openGate is invoked
# if you see a negative timestamp, the server responded before the request was complete
for i in range(20):
engine.queue(target.req, gate='race1')
# once every 'race1' tagged request has been queued
# invoke engine.openGate() to send them in sync
engine.openGate('race1')
非常清晰啊,第一步创建引擎,第二步将要发送的数据准备好放到队列里,第三步把队列中的数据发出并确保同时抵达。关键函数如下:
- 1 首先创建 RequestEngine:
kotlin
open class RequestEngine {
// 存储所有gate
protected val gates = HashMap<String, Floodgate>()
// 请求队列
protected val requestQueue = LinkedBlockingQueue<Request>()
init {
// 根据不同engine类型创建具体实现
when (engineType) {
Engine.SPIKE -> engine = SpikeEngine()
Engine.HTTP2 -> engine = HTTP2RequestEngine()
Engine.THREADED -> engine = ThreadedRequestEngine()
// ...
}
}
}
-
- 当调用 engine.queue() 时:
kotlin
fun queue(template: String, payloads: List<String>, gate: String?) {
// 1. 如果指定了gate,创建或获取Floodgate
val floodgate = if(gate != null) {
gates.getOrPut(gate) { Floodgate(gate, this) }
} else null
// 2. 创建Request对象
val request = Request(
template = template,
payloads = payloads,
gate = floodgate
)
// 3. 将请求加入队列
requestQueue.offer(request)
- 3 在 SpikeEngine 中处理队列中的请求,init时就会启动线程进行发送报文,但此时会阻塞在gate中:
scss
init {
requestQueue = if (maxQueueSize > 0) {
LinkedBlockingQueue(maxQueueSize)
}
else {
LinkedBlockingQueue()
}
idleTimeout *= 1000
threadLauncher = DefaultThreadLauncher()
socketFactory = TrustAllSocketFactory()
target = URL(url)
val retryQueue = LinkedBlockingQueue<Request>()
completedLatch = CountDownLatch(threads)
for(j in 1..threads) {
thread {
// create engine时候会启动发送报文线程,但此时会阻塞
sendRequests(retryQueue)
}
}
}
private fun sendRequests(retryQueue: LinkedBlockingQueue<Request>) {
while (!shouldAbandonAttack()) {
// 1. 阻塞等待获取第一个请求
val req = requestQueue.take() // 这里会等待直到队列中有请求
if (req.gate != null) {
val gatedReqs = ArrayList<Request>()
req.gate!!.reportReadyWithoutWaiting()
// 将queue的报文陆续添加到req里
gatedReqs.add(req)
// 2. 继续收集同一个gate的请求,直到gate打开或所有请求就绪
while (!req.gate!!.isOpen.get() && !shouldAbandonAttack()) {
val nextReq = requestQueue.poll(50, TimeUnit.MILLISECONDS)
?: throw RuntimeException("Gate deadlock")
if (nextReq.gate!!.name != req.gate!!.name) {
throw RuntimeException("Over-read while waiting for gate to open")
}
nextReq.connectionID = connectionID
gatedReqs.add(nextReq)
// 如果所有请求都收集完毕,跳出循环
if (nextReq.gate!!.reportReadyWithoutWaiting()) {
break
}
}
// 3. 开始发送请求...
// 4. 先发送0~last-1的字节
connection.sendFrames(prepFrames)
Thread.sleep(100) // headstart size
for (gatedReq in gatedReqs) {
gatedReq.time = System.nanoTime()
}
// 5. 本地协议栈热身
if (warmLocalConnection) {
val warmer = burp.network.stack.http2.frame.PingFrame("12345678".toByteArray())
// val warmer = burp.network.stack.http2.frame.DataFrame(finalFrames[0].Q, FrameFlags(0), "".toByteArray())
// using an empty data frame upsets some servers
connection.sendFrames(warmer) // just send it straight away
//finalFrames.add(0, warmer)
}
// 6. 先发送last-1~last的字节
for (pair in finalFrames) {
//Utils.out("Sending final frame")
if (pair.second != 0L) {
//Utils.out("Sleeping for "+pair.second)
// fixme response arrives before this frame is sent!
Thread.sleep(pair.second)
}
//Utils.out("Finished sleeping")
connection.sendFrames(pair.first)
}
}
}
}
- 4 当调用 engine.openGate('race1') 时,打开gate,开始陆续发送报文
kotlin
class Floodgate {
fun openGate(gateName: String) {
val gate = gates[gateName] ?: return
// 等待所有请求就绪
while (gate.remaining.get() > 0) {
synchronized(gate.remaining) {
gate.remaining.wait()
}
}
// 打开gate
synchronized(gate.isOpen) {
gate.isOpen.set(true)
gate.isOpen.notifyAll()
}
}
}
4. 如何防护
- 数据库状态操作原子化,例如,使用单个数据库事务来检查付款是否与购物车价值匹配并确认订单。
- 避免混合使用来自不同存储位置的数据
- 在某些架构中,完全避免服务器端状态可能是合适的。相反,我们可以使用加密将状态推送到客户端,例如使用JWT。
- 不要尝试使用一个数据存储层来保护另一层的安全。例如,会话不适合防止对数据库的限制溢出攻击。
- 作深度防御措施,请利用数据存储完整性和一致性功能(例如列唯一性约束)。