analysis of CVE-2021-22205 pre-auth part

CVE-2021-22205在发现之后被拓展为了pre-auth的rce,因此在这里分析一下pre-auth的成因。

rce

rce的部分比较直观,是gitlab在处理上传的图片时采用exiftool对图片进行了处理,然后exiftool本身在处理DjVu格式的文件时存在命令注入导致的。具体的分析文章可以参考漏洞发现者的博文 https://devcraft.io/2021/05/04/exiftool-arbitrary-code-execution-cve-2021-22204.html

pre-auth

gitlab整体结构

主要分析一下pre-auth部分的原因,算是走一遍gitlab的整体流程(正好是第一次分析ruby的应用)。

gitlab的基本结构如下

nginx => workhorse => puma

nginx部分不赘述,workhorse是一个golang写的反向代理,同时承担了部分应用逻辑。比如文件上传的部分就由workhorse在处理,exiftool也是在workhorse中调用的。

puma是真正的ruby后端,用来作为rails应用的服务器。

workhorse

workhorse默认采用unix socket domain交互,路由的基本情况位于 /internal/upstream/routes.go中。

因为gitlab在处理文件上传时做了很多策略(https://docs.gitlab.com/ee/development/uploads.html),并由workhorse承担了很大一部分,所以可以看到 routes.go 中存在大量对于 upload 对象的调用。

以网上公开的poc中提到的 /uploads/user 路由为例,对应的路由策略是

1
u.route("POST", userUploadPattern, upload.Accelerate(api, signingProxy, preparers.uploads))

关注到 Accelerate 函数的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
func Accelerate(rails PreAuthorizer, h http.Handler, p Preparer) http.Handler {
return rails.PreAuthorizeHandler(func(w http.ResponseWriter, r *http.Request, a *api.Response) {
s := &SavedFileTracker{Request: r}

opts, _, err := p.Prepare(a)
if err != nil {
helper.Fail500(w, r, fmt.Errorf("Accelerate: error preparing file storage options"))
return
}

HandleFileUploads(w, r, h, a, s, opts)
}, "/authorize")
}

发现会调用 rails.PreAuthorizeHandler 进行处理,传入的这个函数其实算是一个回调函数。

PreAuthorizeHandler 的实现位于 /internal/api/api.go ,简单来说就是调用 api.PreAuthorize 来做前置的权限校验(这个校验将与puma端交互),如果校验通过则处理回调部分的内容。

api.PreAuthorize的校验在我们分析的这个路由下就是 将原本传递来的请求加上 /authorize 这个后缀,增加一个 Gitlab-Workhorse-Api-Request 的校验头附带一个 jwt token 作为确定请求来自workhorse的校验,然后转发给 puma 后端进行判断。形如

1
2
3
4
5
POST /uploads/user/authorize HTTP/1.1
Host: 127.0.0.1:8080
X-Csrf-Token: konbkeNhzTLlNln7vFG99pGRiiatRtRFvBPlt1C9L47J1wrqIgxLjbiVZ/F1Ji2l96i3HlDOcU1mP9qz/iFanQ==
Cookie: _gitlab_session=4af6cb1c584f51ff7de60cc7bcbd8db9
Gitlab-Workhorse-Api-Request: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRsYWItd29ya2hvcnNlIn0.IiKf6b4dqC99J7Jozf0e91rGjILPtUcwSJ3rcCUNSnU

如果这个请求返回正确,则 workhorse 会继续调用 Accelerate 中的回调函数,继而调用 HandleFileUploads => rewriteFormFilesFromMultipart => handleExifUpload => exif.NewCleaner => startProcessing => exiftool这个最终的程序触发漏洞。

puma

最后也是最麻烦的是puma这部分的分析。之所以麻烦,是因为最开始是在gitlab官方的docker中复现的,但是因为环境耦合度很多,想要单独调试puma部分的ruby应用比较麻烦,因此后面按照gitlab官方的安装教程在虚拟机中搭建了一遍后才有了比较舒服的调试环境。

作为rails的应用,首先是去 config/routes/uploads.rb下确认路由的情况。

1
2
3
post ':model/authorize',
to: 'uploads#authorize',
constraints: { model: /personal_snippet|user/ }

可以发现是调用了 uploads 这个 controller 中的 authorize 函数。

而在 app/controllers/uploads_controller.rb中存在两个 before_action 函数需要先过检查。

1
2
before_action :authorize_create_access!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]

其中 verify_workhorse_api! 主要是检查 jwt 的部分,也就是保证这个请求时从 workhorse 端转发过来的。

主要分析的是 authorize_create_access!这个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
def authorize_create_access!
return unless model

authorized =
case model
when User
can?(current_user, :update_user, model)
else
can?(current_user, :create_note, model)
end

render_unauthorized unless authorized
end

这也是作为第一次分析ruby的我最蛋疼的一个地方,那就是ruby里面的函数和变量在审计的时候很容易混淆。出问题的代码其实就是第一句,return unless model。这里的model是一个函数,在app/controllers/concerns/uploads_actions.rb中实现。

1
2
3
def model
strong_memoize(:model) { find_model }
end

strong_memoize在lib/gitlab/utils/strong_memoize.rb中实现。

1
2
3
4
5
6
7
8
9
10
11
def strong_memoize(name)
if strong_memoized?(name)
instance_variable_get(ivar(name))
else
instance_variable_set(ivar(name), yield)
end
end

def strong_memoized?(name)
instance_variable_defined?(ivar(name))
end

到这里就很清晰了,因为 @model 这个变量在 authorize_create_access调用时还不存在,所以会走到 instance_variable_set 分支。触发 yield 关键字调用到上面的 find_model 函数。

find_model在 uploads_controller.rb 中定义,因为没有传入 id 参数,所以会直接return一个nil。

1
2
3
4
5
def find_model
return unless params[:id]

upload_model_class.find(params[:id])
end

因此其实在 authorize_create_access! 函数中,直接在第一句语句处就会 return。

最后的 authorize 函数反而不需要分析,没有再做什么权限校验,因此达到了 pre-auth 的效果。

漏洞修复

分析这句导致漏洞的 return unless model ,其实会发现非常的多余,因为后面在调用 case model的model时,其实是去调用了strong_memoize,而在 strong_memoize 的定义的说明里就指出了 strong_memoize 的作用就是为了可以不需要采用 return unless 这种写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Instead of writing patterns like this:
#
# def trigger_from_token
# return @trigger if defined?(@trigger)
#
# @trigger = Ci::Trigger.find_by_token(params[:token].to_s)
# end
#
# We could write it like:
#
# include Gitlab::Utils::StrongMemoize
#
# def trigger_from_token
# strong_memoize(:trigger) do
# Ci::Trigger.find_by_token(params[:token].to_s)
# end
# end
#

因此,在最新版本的 gitlab 中也可以发现,authorize_create_access!已经删除了这句 return unless model

对于 exiftool 的修复,gitlab采用了检查上传的图片文件和后缀的对应关系的方式,限制了 exiftool 忽略文件后缀通过文件内容来针对格式进行处理的利用手段。具体commit如下 https://gitlab.com/gitlab-org/gitlab/-/commit/6f8d9618e1e313625f3ee8c84b761fc8ff6c53df

UPDATE

今天@air sky发邮件问了我 https://attackerkb.com/topics/D41jRUXCiJ/cve-2021-22205/rapid7-analysis?referrer=blog 这篇文章的路由是怎么触发的,我才发现原来这个洞的preauth这么彻底,非常骚。
poc的形式就是直接往根url发送一个post请求,形如以下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST / HTTP/1.1
Host: 192.168.126.132
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryr1WeeKiaKhzQzGa7
Content-Length: 857

------WebKitFormBoundaryr1WeeKiaKhzQzGa7
Content-Disposition: form-data; name="file"; filename="1.jpg"
Content-Type: image/jpg

xxxxx
------WebKitFormBoundaryr1WeeKiaKhzQzGa7--

那么为什么直接往根url发送请求就可以触发上传操作呢,简单分析一下/internal/upstream/routes.go的内容即可发现原因。
routeEntry里面规定了所有路由处理的方法,在routeEntry的最后有这么一条路由

1
u.route("", "", defaultUpstream),

也就是当上面的所有路由都匹配失败后,会走到这个默认路由里面。
defaultUpstream的定义如下。

1
2
3
4
5
defaultUpstream := static.ServeExisting(
u.URLPrefix,
staticpages.CacheDisabled,
static.DeployPage(static.ErrorPagesUnless(u.DevelopmentMode, staticpages.ErrorFormatHTML, uploadAccelerateProxy)),
)

大概就是匹配不到静态文件的处理之后就会去调用static.DeployPage来作默认行为。然后最后调用的就是uploadAccelerateProxy这个handle。uploadAccelerateProxy的定义如下,它和上面upload.Accelerate的分析差不多,但是采用了upload.SkipRailsAuthorizer,效果就是不再需要去经过PreAuthorizer的检查,也就是说不再与puma后端交互而直接触发上传的操作。

1
uploadAccelerateProxy := upload.Accelerate(&upload.SkipRailsAuthorizer{TempPath: uploadPath}, proxy, preparers.uploads)

分析到这应该很清楚了,就是workhorse最后的默认路由在匹配不到静态目录下的文件的情况下,默认的处理方式是会处理文件上传这个行为的。导致这个漏洞直接往根url直接POST恶意文件即可触发漏洞。

这些年CTF中做过的Web题

前言

2015年底开始参加CTF以来,参加过很多比赛,本科在BXS team时由于时间过于久远以及当时水平也比较低就不做记录了。以下记录的题目从我代表NeSE开始参加的第一场比赛Hitcon2017为起始点。

2017

BabyFirst Revenge v2

题目链接
题目考点是四字节命令注入。一道考察选手对于linux上各种命令特性了解和使用的题目。当年比赛时肝出这道题让我很开心,印象中当时国内队伍只有我们做出来了。这题和Web的攻击技巧关系不大,主要是对linux上各种命令的掌握程度。

baby^h-master-php-2017

题目链接
应该是phar反序列化这个攻击面被第一次公开地展示到了大家面前。后面经过blackhat上的详细展开,我们现在的题目中才有了这么多phar反序列化的考点(会议链接)。

2021

number manager

刚刚打完asisctf2021,质量出乎意料的高,前端选手的狂欢。这题采用了goahead这个c语言写的http框架作为server,题目大意是给了一个可以创建post的功能,攻击者真正可控的只有创建的子目录名,同时有个dom xss的点在location.hash处使得你可以控制key为msg的一个cookie的值,最终目标是绕过csrf的检查实现xss。考察的其实是对于goahead的代码审计。代码有两个问题,一个是防御csrf时采用websParseCookie函数去获取csrf_token的值,但是websParseCookie的实现存在问题,可以通过控制msg的值伪造csrf_token的值。第二个问题算是一个危害很低的0day,goahead的static handle在处理目录资源是会返回302请求,但是Location处直接返回了目录名,没有作crlf的防御(前提是目录名存在,所以才有创建子目录的功能)。

af2

同样是asisctf2021的一个题目,是一个xsleak,题目直接提示了参考 https://research.securitum.com/stealing-data-in-great-style-how-to-use-css-to-attack-web-application/ 的技术。唯一区别是,这题的csp是script-src 'none'; img-src 'none'; frame-src 'self'; font-src data: ;因此不能采用 -webkit-scrollbar 配合 backgroud 的方式作消息外带。这道题采用了 lazy loading iframe 的方式来外带消息。因为用window.open方式打开的页面中我们是可以拿到加载的frame的数量的,所以采用 lazy loading 的方式可以在滑动屏幕效果产生时load指定的iframe,再通过window.open拿到的window对象来判断是否加载了特定页面来泄露信息。

phpphp

同样是asisctf2021的一个题目,一个php的0day题。。。。题目大意是用python实现了一个http协议的parse,将fastcgi需要的环境变量处理好交给后面的fpm去解析。这里注意处理的细节是 script_filename 是将 document_root 和 script_name 的结果作归一化后传入,但是 script_name 则不做处理直接取request_uri 的到 .php 为止的部分传入,目标是任意文件读取。我们知道在古早的时候 fpm 可以指定 script_filename 为任意文件,但是后面 security.limit_extensions 这个配置的出现基本限制了 script_filename 为特定后缀的文件,比如 .php。这次的漏洞就是绕过了 security.limit_extensions 的限制。漏洞的位置是 main/fopen_wrappers.c 中的 php_fopen_primary_script 函数,主要是把传给fpm的环境变量中的SCRIPT_NAME进行了解析作为后续要打开的文件的handle,具体的poc就不展示了,有兴趣的同学看看代码应该就能构造出来。

todo

上次更新时间: 2021年10月22日
最新更新时间: 2021年10月27日
持续更新…

初衷

之前也潦草地创建过一个博客,内容很少,两年多只更新了两篇CTF的WriteUp以及一个学习Java的备忘录。之所以不更新博客,主要是因为人懒,知识习惯性地记录在脑子里,想到需要整理成文、排版发布就放弃了。还有一方面原因,是因为在我狭隘的认识里,一直觉得网络安全是一个沉默的战场,一个新颖的漏洞发现或者利用技术的公开都意味着迅速贬值。基于以上的原因,博客就一直长草搁置于我的电脑中。
但随着年龄以及学习到的各方面知识的增多,我愈发感觉到系统地整理自己的知识,以某种媒介的形式存储下来是非常有必要的。所以打算重新开启自己的博客,主要是记录最近学习和研究的内容,基于我狭隘的世界观,这里也不会出现什么新颖的思路,只是一些公开内容的二次学习资料,在此事先说明。
最后,作为一个从2015年开始接触CTF的老年人,也算是度过了单独为一个题目写WriteUp的年纪,之前觉得wupco博客整理自己觉得不错的CTF题目的形式挺好(wupco博客链接)。所以,后面就单独开一篇文章(链接)回忆以及持续记录这些年参加过的CTF比赛中遇到的Web题目,不再单独写某个题目的WriteUp了。
最后的最后,选择这个最简单最基础的hexo默认主题也是希望自己无论是作研究还是做人都可以单纯简单一些,不忘初心,不悔前尘。