探索协程世界:原理、现实与漏洞模式(下篇)
探索协程世界:原理、现实与漏洞模式(下篇)
原创 l1nk 奇安信天工实验室 2025-02-19 03:30
目
录
一、anyio – Race Condition
二、dashmap – Coroutine Dead Lock
三、containerd – CVE-2022-23471 memory exhaustion
四、envoy – coroutine with UAF CVE-2023-27492
五、
Conclusion
在本文的上篇
,介绍了关于协程的相关理论,通过厨师模型讲解了协程代码模型的产生过程,相信读者已经对协程有了大致的认识。接下来将会介绍历史发生过的与协程相关的安全问题。由于协程在不同语言的实现差异,会导致其呈现的形式也会有所差异。本篇将选取几个比较有特征的案例介绍。
一
anyio – Race Condition
这个问题并没有获得CVE编号,是在用户进行测试时发现的。
anyio
是一个 Python 异步网络并发库,可以在
trio
或
者
asyncio
上
运行。其完美的兼容了
asyncio
和
trio
,可以很方便的让代码进行重构。
然而这个库存在一个奇怪的问题
,当尝试使用多线程访问这个库的时候,有概率触发一个库未初始化的问题。经过开发者定位,错误的代码位置如下:
这段代码的逻辑本质上为取出
anyio
中实现的另一个
module
对象并返回。实际上,在普通程序中他也被这样使用:
可以看到,其取出来的库是提供
支持协程异步函数
的库。然而,这种写法其实存在一定的迷惑性。例如:
在这种写法中,这里的
asy
函数会是异步的吗?虽然这个函数前面存在一个
await
,但是实际上这个
await
是针对
bfunc
,而并非
asy
。所以上述的
get_async_backend
这个函数这个本身在设计之初实际上也并非完全考虑了异步的状态。
在上述代码中,下列的代码可能会存在竞争的问题:
这里的
sys.modules
是一个Python内置的全局对象,当我们第一次访问的时候,模块会尝试加载模块,然而此时
存在一个竞争窗口
,我们用数字描述线程的话,假定现在有两个线程,其分别运行到如下的位置:
实际上,这里的
sys.modules
就是一个普通的字典,不存在线程保护。当这个模块还未彻底加载的时候,
sys.modules[modulename]
就已经会被初始化:
此时,如果第二个线程来到上面指定的位置就加入的场合,就可能
获取一个还未加载完成的模块
。
在这个场景下的线程2如果使用这个模块,就会造成一个条件竞争。
01
修复策略
对于这个漏洞的修复策略,官方的解决方案
也很简单,就是自己本地维护一个加载的字典,这样就能保证在当前线程中,这个模块是安全的:
通过使用了一个自身维护的字典
loaded_backends
,保证其一定存放的是加载后的模块而非加载前的,从而避免问题的出现。
02
问题总结
这个问题本质上和协程关联不大,但是却和协程的存在形式有一定的关系。实际上API使用的时候,因为取出的模块实现的API支持协程,大多数情况下使用的模式都类似于:
这种写法会让开发者产生一定的疑惑,认为
get_async_backend
为支持异步的函数。而实际上,在最初的设计中,API是无法支持异步的。在使用API的时候,需要检查其内部是否为线程安全,否则的话可能需要优先考虑加锁,抑或是实现其他的
workaround
函数来回避这个问题。
二
dashmap – Coroutine Dead Lock
dashmap
在Rust中是一种对于
hashmap
的封装,可以简单的将其理解为对于
RwLock
的抽象。这样在用户使用
hashMap
的时候,能够最大程度的保证对
hashmap
操作的原子性,从而保证其在多线程中的安全。
dashmap
中,如果我们尝试访问一个对象,那么他会给我们加上一个读锁,如果我们尝试修改一个对象,它会给我们加上一个写锁。
dashmap
在使用的过程中,存在一个容易出现的问题。这个问题笔者在实际的开发中也遇到过 。问题说明详见
dashmap
的官方文档
,其例子大致如下:
与上一个问题不同的是,这个问题
只会发生在协程中
。为了更加简化程序,假设在这个模型中,只存在一个线程以及两个协程AB,那么此时问题的现象如下:
– 协程A 来到了
get
的逻辑中,获取了读锁,并且获取了分片a,然后因为
delay.await
的原因切换出去
- 协程B 来到了
get_mut
逻辑中,获取写锁,然而由于协程A已经获取了读锁,协程B陷入了死锁阶段
换句话说,在上述代码中,极易出现
死锁
现象,其会表现成
所有的协程都卡死
的这种状态。
01
为什么会死锁?程序不会继续运行吗?
这个是一个很有意思的问题,因为在传统的线程模型中,这个场景理论上不会发生死锁,他的执行逻辑大概是这样的:
– 线程A 来到了
get
的逻辑中,获取了读锁,并且获取了分片a,接下来遇到
thread::sleep
函数之后,程序切换上下文来到另一个线程
-
线程B 经过了前面的
get
逻辑,并且穿过了
sleep
,来到了
get_mut
逻辑中,获取写锁,然而由于线程A已经获取了读锁,线程B陷入锁状态,此时被调度出来 -
线程A 完成睡眠操作,来到后续的
get_mut
操作,由于此时没有锁,于是顺利执行完成 -
线程B 等线程A执行完成后,成功获得锁,也顺利执行完成
但是,在协程中,这个问题却无法得到解决,这是为什么呢?这就要回到最初聊到的
协程的原理
上。协程并非线程,他的切换上下文
是由程序本身控制的
,这就意味着,程序
无法从自身的运行状态中脱离
。回到这个代码中,前面说过,协程本质上是编译器提供的辅助代码实现的,所以这里可以尝试检查这一段rust代码展开辅助代码后的形式。可以看到它展开后的代码如下:
可以看到,这里的实现和上篇中提到的
厨师模型
中的协程模式很像,也是使用了生成器编程模式视线的临时性退出函数。然而仔细看这里的逻辑会发现,程序只有在尝试完成了
get_mut
函数的调用,才会去调用后面的
poll
函数和
yield
函数。所以此处
无法发生上下文的切换
,自然也不能像之前的多线程模式,强行的切换到另一个线程从而解开锁。
02
解决方法
要解决这个问题也很简单,在Rust开发中,声明周期其实非常重要,所以这里只要保证
dashmap
取出的对象生命周期不要越过await
即可回避这个问题。一种比较丑陋的解决办法是:
实际上,这里的处理逻辑完全可以通过一些别的方法进行回避,这就只能case-by-case的解决了。
三
containerd – CVE-2022-23471 memory exhaustion
每一个用docker的人应该都听过这个库,实际上,它就是近些年来
容器生态
中最关键也最流行的底层组件。
containerd
为云原生环境提供了稳定的基础设施支持。
然而在这个库中,也存在着一个因为协程而导致的内存耗尽的问题,利用这个问题会导致宿主机的运行环境内存耗尽,最终实现对整个宿主的DoS攻击。
在官方公告中提到,漏洞出现在
containerd
的CRI(Container Runtime Interface)中,当用户尝试利用某些指令执行容器指令,并且这个指令会要求容器提供的
TTY
的时候,容器会发起一个
resizeEvent
,然而如果此时的指令由于某种原因未能成功执行,例如用户命令错误或故障,就会导致处理
resizeEvent
的协程卡住,从而造成一个内存耗尽的问题。
不过在谈到go的协程的时候,由于go的协程和其他语言的协程有所差异,这里首先展示一个go语言中比较特殊的一个点。
01
goroutine 与 channel
在go语言中,协程的实现有点差异,其运行的协程被称之为
goroutine
,并且使用了一种叫做
channel
的概念来实现其他语言中类似同步的功能。当需要获得某个数据的运行结果的时候,go语言会使用
channel
将这个数据送出去,此时
goroutine
会处于
hang
的状态,直到存在一个
receiver
将这个值接受后,
goroutine
才会继续运行。
下面的例子展示了go语言中常见的死锁场景例:
由于在主线程中加入了
WaitGroup
,只有
goroutine
执行结束之后,主程序才会结束。然而,在下列代码运行的结果会提示
所有的
goroutine
都卡死了
,因为这里的
channel
找不到接收对象,所以go语言会直接报错,结束这个程序。然而在现实中,这种死锁的
goroutine
只会不断的增加个,从而造成资源的消耗。
02
containerd 中的死锁位置
在知道了漏洞点后,这里首先列出有问题的关键函数:
在函数
handleResizeEvents
中,能看到这个
channel <- size
,这个
channel
的定义为
channel chan<- remotecommand.TerminalSize
。显然这个
channel
就是前文提到过的用于数据传输的
channel
。从函数名可以推断,这个函数的作用是用来
检查一个
resize
事件
,于是可以检查对应的事件函数的位置,可以找到如下的函数:
根据参数可知,在处理
command
的时候才会调用这个函数,于是可以检查调用处的函数,这里以
exec
为例:
以
exec
指令为例,我们能看到,这里的
execInternal
中显示的调用了
handleResizing
,然而其的调用存在一个前提,那就是
Task.exec
执行成功
。这个
tast.exec
其实就是对指令的执行。显然,当没能正确的调用
exec
的时候,这个协程就会因为错误提前退出,此时的
handleResizing
将不会得到合适的机会执行。
从问题链接描述中可以知道,当出现下列情况的时候,就会造成内存泄露的问题:
– 容器支持
unix socket
- 用户使用
kubectl
或者
crictl
指令(未测试,但是猜测docker指令也可)执行一个容器指令,并且容器指令执行错误
如果满足上述条件,
goroutine
就会慢慢增长,最终导致内存耗尽。
在实际场景中,虽然绝大多数的场合中,用户都只能对容器内部进行访问,然而在运维场景场景下,攻击者就有可能会控制这些传递的指令,从而对控制集群的机器本身进行内存耗尽攻击。
03
修复策略
实际上,
containrd
在指令执行错误等场景下,会修改
ctx
的执行状态。所以修复的时候,只需要增加对这个状态的判断,即可回避这个问题:
四
envoy – CVE-2023-27492 coroutine with UAF
在之前描述的漏洞中,似乎只提到了可能造成拒绝服务的问题,这边要展示一个协程可能会造成的比较严重的UAF问题。
Envoy
是一个
云原生高性能边缘/中间层/服务代理
,广泛用于微服务架构和现代应用程序的网络流量管理。其主要也是对容器化,动态调度和微服务提供支持。
Envoy
支持多种插件,其中它的HTTP过滤器支持使用lua语言在请求和相应流中编写脚本:
在Lua中,也是支持
coroutine
这个特性的,在这个库中存在的Lua引擎自然也是实现了这一点。然而,由于这个Lua脚本本质上为对HTTP过滤器的支持,导致Lua引擎部分与库本身的运行逻辑绑定,从而诱发了一个问题。
01
程序运行与UAF
在
Envoy HTTP filters
的处理逻辑中,为了防止输入数据太大,其允许手动设置
单次可以处理的数据
。如果需要处理的数据太大了,其会调用一个叫做
SendLocalReply
的函数,直接构造一个返回数据包,并且标记当前
Filter
为可销毁对象。
可以看到,程序主要是对一个
缓存区buffer
的大小进行了限制。当过滤器对请求数据进行存储的时候,这个缓存区就会被创建。
程序提供的各类过滤器中,
Lua filter
可以对接收到的数据进行存储,修改,甚至自己构造新的数据。在请求数据到达的时候,
Filter
可以进行数据处理,其的处理逻辑如下:
代码中可以看到不同的状态,这些状态用于描述当前Lua 脚本运行状态。根据不同的运行状态,它会来到不同的书记处理流程。如果此时
Lua Filter
中,程序尝试保留当前请求,例如调用
local initial_req_body = request_handle:body()
获取数据的时候,其会调用的
addData
中,将数据保存在本地。此时其中的
addData
包含如下的逻辑:
可以看到,当
filter
保留数据的时候,此时程序会将数据缓存在前文提到的
buffer
缓存区中。在这个缓存区中,会检查当前数据的大小是否超出了预期。如果超出预期的情况下,数据会调用
SendLocalReply
,尝试异步的发送返回数据包。当程序发现当前
Filter
已经处理过回显数据包后,会销毁当前关联的上下文,
包含对应的
filter:
然而,当程序发送返回数据包的时候,其产生的
response
会再次触发
Lua Filter
,然而由于该对象已经被销毁,所以在运行的时候会造成
segmentation fault
。
可以从官方的PoC
来学习这个漏洞的成因:
最初的时候,lua脚本截获了一个请求(假定这个请求的数据特别大)
此时,lua脚本获取这个请求
body
:
此时请求的数据大小被设置为能够接受的
buffer
限制的最大值+1,超过了最大值(参考官方
PoC
这里写的)
回到Lua 这边,此时的
Filter
解析会来到这个逻辑
,然而由于数据过大,当这条逻辑执行完成的时候,会提示
Envoy
的处理逻辑会发出错误提醒:
但是由于函数本身是异步的原因,此时的程序并不会当即发送
response
,而是进行了标记。同时由于发送了
reply
,当前
filter
被标记可被消除的状态,此时会调用一个叫做
OnReset
的函数,标记当前
HTTP
请求已经被重置了:
然而对于lua层面,其无法感知到这个过程,所以其还是顺利的来到了
coroutine
协程的逻辑,协程直接退出至正常逻辑,并且来到后续的处理逻辑上:
此时对应的
filter
会产生一个新的
HTTP
请求,然而在这个请求过程中,由于其为
异步请求
,所以此时程序发送完数据后,会将之前挂起的异步对象的生命周期进行处理,其中就包括
被标记为清除的filter
,于是此时的
lua filter
被清除。
然而,
Filter
发起的请求最终还是会产生一个相应的响应数据,此时之前被挂起的协程被调度,包括
之前
addData
后的那个协程
,然而对应的
filter
早在之前因为
OnReset
被清除,所以此时访问了一个不可访问的对象,最终导致了漏洞的发生。
02
修复策略
为了避免协程的问题, 其修复逻辑如下:
此时,当再次尝试调用
resume
恢复对应的Lua 协程对象的时候,由于看到这个
on_reset_called_
被设置,主要逻辑不在会再尝试拉起对应的
Lua coroutine
,从而避免了问题的出现。
五
Conclusion
与传统的多线程安全问题相比,协程安全模型引入了几个不同的要点:
– 开发者编写的代码,与实际生成的代码存在一定的差异
-
协程并非操作系统特性,这意味着改特性并不受到操作系统的安全防护
-
与线程相比,协程本质上是一种
手工切换上下文
的过程
上述特点就导致了这个特性存在一些与传统安全模型的差异,既无法看到其完整的调用逻辑,在开发过程中需要时刻对齐资料。例如,协程会导致程序从执行过程中切换出去,这就意味着传统线程安全中的
局部变量
的范围需要重新考量。在使用这类特性的时候,开发者需要时刻保持对语言特性的知识对齐,才能保证其使用的安全性。
【版权说明】
本作品著作权归l1nk
所有
未经作者同意,不得转载
l1nk
天工实验室安全研究员,Datacon 2023 出题人,主攻二进制漏洞挖掘。
往期回顾
01
02
Java XStream 反序列化:Gadget 挖掘思路分享
03
04
CVE-2024-38054 Windows ksthunk.sys驱动提权漏洞分析
每周三更新一篇技术文章 点击关注我们吧!