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恶意文件即可触发漏洞。