探索协程世界:原理、现实与漏洞模式(下篇)

探索协程世界:原理、现实与漏洞模式(下篇)

原创 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
,可以很方便的让代码进行重构。

然而这个库存在一个奇怪的问题
,当尝试使用多线程访问这个库的时候,有概率触发一个库未初始化的问题。经过开发者定位,错误的代码位置如下:

screenshot (1).png

这段代码的逻辑本质上为取出
anyio
中实现的另一个
module
对象并返回。实际上,在普通程序中他也被这样使用:

screenshot (2).png

可以看到,其取出来的库是提供
支持协程异步函数
的库。然而,这种写法其实存在一定的迷惑性。例如:

screenshot (3).png

在这种写法中,这里的
asy
函数会是异步的吗?虽然这个函数前面存在一个
await
,但是实际上这个
await
是针对
bfunc
,而并非
asy
。所以上述的
get_async_backend
这个函数这个本身在设计之初实际上也并非完全考虑了异步的状态。

在上述代码中,下列的代码可能会存在竞争的问题:

screenshot (4).png

这里的
sys.modules
是一个Python内置的全局对象,当我们第一次访问的时候,模块会尝试加载模块,然而此时
存在一个竞争窗口
,我们用数字描述线程的话,假定现在有两个线程,其分别运行到如下的位置:

screenshot (5).png

实际上,这里的
sys.modules
就是一个普通的字典,不存在线程保护。当这个模块还未彻底加载的时候,
sys.modules[modulename]
就已经会被初始化:

screenshot (6).png

此时,如果第二个线程来到上面指定的位置就加入的场合,就可能
获取一个还未加载完成的模块

在这个场景下的线程2如果使用这个模块,就会造成一个条件竞争。

01

修复策略

对于这个漏洞的修复策略,官方的解决方案
也很简单,就是自己本地维护一个加载的字典,这样就能保证在当前线程中,这个模块是安全的:

screenshot (7).png

通过使用了一个自身维护的字典
loaded_backends
,保证其一定存放的是加载后的模块而非加载前的,从而避免问题的出现。

02

问题总结

这个问题本质上和协程关联不大,但是却和协程的存在形式有一定的关系。实际上API使用的时候,因为取出的模块实现的API支持协程,大多数情况下使用的模式都类似于:

screenshot (8).png

这种写法会让开发者产生一定的疑惑,认为
get_async_backend
为支持异步的函数。而实际上,在最初的设计中,API是无法支持异步的。在使用API的时候,需要检查其内部是否为线程安全,否则的话可能需要优先考虑加锁,抑或是实现其他的
workaround
函数来回避这个问题。

dashmap – Coroutine Dead Lock

dashmap
在Rust中是一种对于
hashmap
的封装,可以简单的将其理解为对于
RwLock>
的抽象。这样在用户使用
hashMap
的时候,能够最大程度的保证对
hashmap
操作的原子性,从而保证其在多线程中的安全。

dashmap
中,如果我们尝试访问一个对象,那么他会给我们加上一个读锁,如果我们尝试修改一个对象,它会给我们加上一个写锁。

dashmap
在使用的过程中,存在一个容易出现的问题。这个问题笔者在实际的开发中也遇到过 。问题说明详见
dashmap
官方文档
,其例子大致如下:

screenshot (9).png

与上一个问题不同的是,这个问题
只会发生在协程中
。为了更加简化程序,假设在这个模型中,只存在一个线程以及两个协程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代码展开辅助代码后的形式。可以看到它展开后的代码如下:

screenshot (10).png

可以看到,这里的实现和上篇中提到的
厨师模型
中的协程模式很像,也是使用了生成器编程模式视线的临时性退出函数。然而仔细看这里的逻辑会发现,程序只有在尝试完成了
get_mut
函数的调用,才会去调用后面的
poll
函数和
yield
函数。所以此处
无法发生上下文的切换
,自然也不能像之前的多线程模式,强行的切换到另一个线程从而解开锁。

02

解决方法

要解决这个问题也很简单,在Rust开发中,声明周期其实非常重要,所以这里只要保证
dashmap
取出的对象生命周期不要越过await
即可回避这个问题。一种比较丑陋的解决办法是:

screenshot (11).png

实际上,这里的处理逻辑完全可以通过一些别的方法进行回避,这就只能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语言中常见的死锁场景例:

screenshot (12).png

由于在主线程中加入了
WaitGroup
,只有
goroutine
执行结束之后,主程序才会结束。然而,在下列代码运行的结果会提示
所有的
goroutine
都卡死了
,因为这里的
channel
找不到接收对象,所以go语言会直接报错,结束这个程序。然而在现实中,这种死锁的
goroutine
只会不断的增加个,从而造成资源的消耗。

02

containerd 中的死锁位置

在知道了漏洞点后,这里首先列出有问题的关键函数:

screenshot (13).png

在函数
handleResizeEvents
中,能看到这个
channel <- size
,这个
channel
的定义为
channel chan<- remotecommand.TerminalSize
。显然这个
channel
就是前文提到过的用于数据传输的
channel
。从函数名可以推断,这个函数的作用是用来
检查一个
resize
事件
,于是可以检查对应的事件函数的位置,可以找到如下的函数:

screenshot (14).png

根据参数可知,在处理
command
的时候才会调用这个函数,于是可以检查调用处的函数,这里以
exec
为例:

screenshot (15).png


exec
指令为例,我们能看到,这里的
execInternal
中显示的调用了
handleResizing
,然而其的调用存在一个前提,那就是
Task.exec
执行成功
。这个
tast.exec
其实就是对指令的执行。显然,当没能正确的调用
exec
的时候,这个协程就会因为错误提前退出,此时的
handleResizing
将不会得到合适的机会执行。

从问题链接描述中可以知道,当出现下列情况的时候,就会造成内存泄露的问题:
– 容器支持
unix socket

  • 用户使用
    kubectl
    或者
    crictl
    指令(未测试,但是猜测docker指令也可)执行一个容器指令,并且容器指令执行错误

如果满足上述条件,
goroutine
就会慢慢增长,最终导致内存耗尽。

在实际场景中,虽然绝大多数的场合中,用户都只能对容器内部进行访问,然而在运维场景场景下,攻击者就有可能会控制这些传递的指令,从而对控制集群的机器本身进行内存耗尽攻击。

03

修复策略

实际上,
containrd
在指令执行错误等场景下,会修改
ctx
的执行状态。所以修复的时候,只需要增加对这个状态的判断,即可回避这个问题:

co13.png

co14.png

envoy – CVE-2023-27492 coroutine with UAF

在之前描述的漏洞中,似乎只提到了可能造成拒绝服务的问题,这边要展示一个协程可能会造成的比较严重的UAF问题。

Envoy
是一个
云原生高性能边缘/中间层/服务代理
,广泛用于微服务架构和现代应用程序的网络流量管理。其主要也是对容器化,动态调度和微服务提供支持。

Envoy
支持多种插件,其中它的HTTP过滤器支持使用lua语言在请求和相应流中编写脚本:

screenshot (16).png

在Lua中,也是支持
coroutine
这个特性的,在这个库中存在的Lua引擎自然也是实现了这一点。然而,由于这个Lua脚本本质上为对HTTP过滤器的支持,导致Lua引擎部分与库本身的运行逻辑绑定,从而诱发了一个问题。

01

程序运行与UAF


Envoy HTTP filters
的处理逻辑中,为了防止输入数据太大,其允许手动设置
单次可以处理的数据
。如果需要处理的数据太大了,其会调用一个叫做
SendLocalReply
的函数,直接构造一个返回数据包,并且标记当前
Filter
为可销毁对象。

screenshot (17).png

可以看到,程序主要是对一个
缓存区buffer
的大小进行了限制。当过滤器对请求数据进行存储的时候,这个缓存区就会被创建。

程序提供的各类过滤器中,
Lua filter
可以对接收到的数据进行存储,修改,甚至自己构造新的数据。在请求数据到达的时候,
Filter
可以进行数据处理,其的处理逻辑如下:

screenshot (18).png

代码中可以看到不同的状态,这些状态用于描述当前Lua 脚本运行状态。根据不同的运行状态,它会来到不同的书记处理流程。如果此时
Lua Filter
中,程序尝试保留当前请求,例如调用
local initial_req_body = request_handle:body()
获取数据的时候,其会调用的
addData
中,将数据保存在本地。此时其中的
addData
包含如下的逻辑:

screenshot (19).png

可以看到,当
filter
保留数据的时候,此时程序会将数据缓存在前文提到的
buffer
缓存区中。在这个缓存区中,会检查当前数据的大小是否超出了预期。如果超出预期的情况下,数据会调用
SendLocalReply
,尝试异步的发送返回数据包。当程序发现当前
Filter
已经处理过回显数据包后,会销毁当前关联的上下文,
包含对应的
filter:

screenshot (20).png

然而,当程序发送返回数据包的时候,其产生的
response
会再次触发
Lua Filter
,然而由于该对象已经被销毁,所以在运行的时候会造成
segmentation fault

可以从官方的PoC

来学习这个漏洞的成因:

screenshot (21).png

最初的时候,lua脚本截获了一个请求(假定这个请求的数据特别大)

co15.png

此时,lua脚本获取这个请求
body

screenshot (22).png

此时请求的数据大小被设置为能够接受的
buffer
限制的最大值+1,超过了最大值(参考官方
PoC
这里写的)

screenshot (23).png

回到Lua 这边,此时的
Filter
解析会来到这个逻辑
,然而由于数据过大,当这条逻辑执行完成的时候,会提示
Envoy
的处理逻辑会发出错误提醒:

co16.png

但是由于函数本身是异步的原因,此时的程序并不会当即发送
response
,而是进行了标记。同时由于发送了
reply
,当前
filter
被标记可被消除的状态,此时会调用一个叫做
OnReset
的函数,标记当前
HTTP
请求已经被重置了:

screenshot (24).png

然而对于lua层面,其无法感知到这个过程,所以其还是顺利的来到了
coroutine
协程的逻辑,协程直接退出至正常逻辑,并且来到后续的处理逻辑上:

screenshot (25).png

此时对应的
filter
会产生一个新的
HTTP
请求,然而在这个请求过程中,由于其为
异步请求
,所以此时程序发送完数据后,会将之前挂起的异步对象的生命周期进行处理,其中就包括
被标记为清除的filter
,于是此时的
lua filter
被清除。

co17.png

然而,
Filter
发起的请求最终还是会产生一个相应的响应数据,此时之前被挂起的协程被调度,包括
之前
addData
后的那个协程
,然而对应的
filter
早在之前因为
OnReset
被清除,所以此时访问了一个不可访问的对象,最终导致了漏洞的发生。

co18.png

02

修复策略

为了避免协程的问题, 其修复逻辑如下:

co19.png

co20.png

此时,当再次尝试调用
resume
恢复对应的Lua 协程对象的时候,由于看到这个
on_reset_called_
被设置,主要逻辑不在会再尝试拉起对应的
Lua coroutine
,从而避免了问题的出现。

Conclusion

与传统的多线程安全问题相比,协程安全模型引入了几个不同的要点:
– 开发者编写的代码,与实际生成的代码存在一定的差异

  • 协程并非操作系统特性,这意味着改特性并不受到操作系统的安全防护

  • 与线程相比,协程本质上是一种
    手工切换上下文
    的过程

上述特点就导致了这个特性存在一些与传统安全模型的差异,既无法看到其完整的调用逻辑,在开发过程中需要时刻对齐资料。例如,协程会导致程序从执行过程中切换出去,这就意味着传统线程安全中的
局部变量
的范围需要重新考量。在使用这类特性的时候,开发者需要时刻保持对语言特性的知识对齐,才能保证其使用的安全性。

【版权说明】

本作品著作权归l1nk
所有

未经作者同意,不得转载

lx_clip1739258040397.png


l1nk

天工实验室安全研究员,Datacon 2023 出题人,主攻二进制漏洞挖掘。

往期回顾

01

探索协程世界:原理、实现与漏洞模式(上篇)

02

Java XStream 反序列化:Gadget 挖掘思路分享

03

Android保护机制及利用技巧总结

04

CVE-2024-38054 Windows ksthunk.sys驱动提权漏洞分析

每周三更新一篇技术文章  点击关注我们吧!