漏洞介绍
GitLab是一款Ruby开发的Git项目管理平台。在11.9以后的GitLab中,因为使用了图片处理工具ExifTool而受到漏洞CVE-2021-22204的影响,攻击者可以通过一个未授权的接口上传一张恶意构造的图片,进而在GitLab服务器上执行任意命令。
漏洞范围
- Gitlab CE/EE < 13.10.3
- Gitlab CE/EE < 13.9.6
- Gitlab CE/EE < 13.8.8
漏洞靶场
使用vulhub的靶场:
使用环境为GitLab 13.10.1
vulhub-master/gitlab/CVE-2021-22205/
启动靶场环境:
1 | docker-compose up -d |
环境启动后,访问http://your-ip:8080即可查看到GitLab的登录页面。
漏洞原理
GitLab Workhorse介绍
GitLab Workhorse是一个使用go语言编写的敏捷反向代理。在gitlab_features说明中可以总结大概的内容为,它会处理一些大的HTTP请求,比如文件上传、文件下载、Git push/pull和Git包下载。其它请求会反向代理到GitLab Rails应用。可以在GitLab的项目路径lib/support/nginx/gitlab中的nginx配置文件内看到其将请求转发给了GitLab Workhorse。默认采用了unix socket进行交互。
workhorse路由匹配
在workhorse的更新中涉及函数有NewCleaner,在存在漏洞的版本13.10.2中跟踪到该函数,其中调用到startProcessing来执行exiftool命令
1 | func NewCleaner(ctx context.Context, stdin io.Reader) (io.ReadCloser, error) { |
workhorse/internal/upload/exif/exif.go#L25-L33
右键该方法浏览调用结构


从上图中除去带test字样的测试函数,可以看出最终调用点只有两个,upload包下的Handler函数Accelerate,和artifacts包下的Handler函数UploadArtifacts。现在还暂时不确定是哪个函数,根据前面的漏洞描述信息我们知道对接口/uploads/user的处理是整个调用链的开始,所以直接在源码中全局搜索该接口

workhorse/internal/upstream/routes.go#L63
由于请求会先经过GitLab Workhorse,我们可以直接在上图中确定位于workhorse/internal/upstream/routes.go路由文件中的常量userUploadPattern,下面搜索一下对该常量的引用

workhorse/internal/upstream/routes.go#L315
在315行代码中发现进行了路由匹配,然后调用了upload.Accelerate。和前面调用点Accelerate吻合,这里的调用比较关键,接下来分析该函数:
1 | func Accelerate(rails PreAuthorizer, h http.Handler, p Preparer) http.Handler { |
workhorse/internal/upload/accelerate.go#L20-L32
可以看到函数返回值为http.Handler,说明了之前在ServeHTTP中进行了调用。我们可以尝试一下寻找前面的ServeHTTP调用点。
首先可以看到路由注册在结构体routeEntry中,然后返回了一个数组赋值给u.Routes。

1 | routeEntry`用于储存请求路径和对应handler。以下是路由注册方法`route`,接收者为`upstream`结构体。实现功能传入正则字符串形式路径和对应处理handler存入`routeEntry |
workhorse/internal/upstream/routes.go#L108-L131
upstream结构体的成员Routes指向一个routeEntry数组。
1 | type upstream struct { |
workhorse/internal/upstream/upstream.go#L33-L40
查看对该成员的操作位置,位于upstream的ServeHTTP方法中,这里通过遍历u.Routes调用isMatch对全局请求进行了路由匹配,最后调用相应的handler。
1 | func (u *upstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
workhorse/internal/upstream/upstream.go#L84-L130
isMatch方法如下,使用regex.MatchString()判断了请求路由是否匹配,cleanedPath为请求url。
1 | func (ro *routeEntry) isMatch(cleanedPath string, req *http.Request) bool { |
workhorse/internal/upstream/routes.go#L152-L170

workhorse认证授权
Accelerate函数中有两个参数,一个是传入的handler,一个是原有的请求上加上接口authorize。文档中写到接口用于认证授权。

函数内的PreAuthorizeHandler是PreAuthorizer接口的一个接口方法。该方法实现了一个中间件功能,作用是进行指定操作前的向rails申请预授权,授权通过将调用handler函数体内的HandleFileUploads上传文件。下面是PreAuthorizer接口定义。
1 | type PreAuthorizer interface { |
workhorse/internal/upload/body_uploader.go#L15-L17
接口实现位于internal\api\api.go:265,以下贴出删减后的关键代码:
1 | func (api *API) PreAuthorizeHandler(next HandleFunc, suffix string) http.Handler { |
workhorse/internal/api/api.go#L265-L290
其中使用了http.HandlerFunc将普通函数转换成了Handler类型,跟进api.PreAuthorize(suffix, r),
1 | func (api *API) PreAuthorize(suffix string, r *http.Request) (httpResponse *http.Response, authResponse *Response, outErr error) { |
workhorse/internal/api/api.go#L230-L263
以上代码中newRequest()用于组装请求头,跟进如下:
1 | func (api *API) newRequest(r *http.Request, suffix string) (*http.Request, error) { |
workhorse/internal/api/api.go#L183-L220
doRequestWithoutRedirects()用于发起请求,跟进如下:
1 | func (api *API) doRequestWithoutRedirects(authReq *http.Request) (*http.Response, error) { |
workhorse/internal/api/api.go#L292-L296
doRequestWithoutRedirects()第一行实例化使用一个RoundTripper,传入了http.Client的Transport类型。RoundTripper是一个接口,可以当做是基于http.Client的中间件,在每次请求之前做一些指定操作。实现其中的RoundTrip方法即可实现接口做一些请求前的操作。下面看看在RoundTrip方法中做了什么
1 | func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { |
workhorse/internal/secret/roundtripper.go#L23-L35

上图中添加了header头Gitlab-Workhorse-Api-Request,内容为JWT令牌,用于在rails中验证请求是否来自于workhorse。最后组成的请求为
1 | POST /uploads/user/authorize HTTP/1.1 |
当得到响应后在PreAuthorize方法结尾通过json.NewDecoder(httpResponse.Body).Decode(authResponse)解析json数据httpResponse.Body到authResponse中,authResponse指向了Response结构体,定义如下:
1 | type Response struct { |
workhorse/internal/api/api.go#L108-L152
总结下这部分的调用结构和流程:

gitlab-rails处理认证请求
rails部分的处理是比较关键的,只有在rails正确授权才能上传文件。rails中关于uploads接口的路由文件位于config/routes/uploads.rb内。其中一条路由规则为
1 | post ':model/authorize', |
config/routes/uploads.rb#L38-L40
请求/uploads/user/authorize将匹配这条规则,调用controlleruploads中的actionauthorize。
controller定义位于app/controllers/uploads_controller.rb,在头部include了UploadsActions所在的文件。在其中摘抄出关键的代码如下:
1 | class UploadsController < ApplicationController |
app/controllers/uploads_controller.rb#L3-L90
authorize定义位于app/controllers/concerns/uploads_actions.rb。代码如下:
1 | def authorize |
app/controllers/concerns/uploads_actions.rb#L55-L65
在UploadsController中要调用到authorize还需要先执行前面定义的before_action指定的方法authorize_create_access!和verify_workhorse_api!。一个用于验证上传权限,一个用于检测请求jwt的部分保证来自workhorse。
首先使用exp进行测试,代码如下:
1 | import sys |

通过pry-shell调试,请求到达authorize_create_access!。return unless model表示调用model方法只要结果不为真也就是为假就会return。手动调用一下发现返回了nil。

使用step进行步入。

model方法位于uploads_actions.rb中,接下来调用strong_memoize传入语句块{ find_model },将判断实例变量@model是否定义。该方法位于lib/gitlab/utils/strong_memoize.rb中,代码如下:
1 | module Gitlab |
lib/gitlab/utils/strong_memoize.rb#L5-L49
官方文档介绍中解释是用于简化对于实例变量的存取。

代码中@model为nil

所以会走到else中替换掉yield关键字为传入块中的find_model方法并执行来查找设置实例变量@model,该方法位于UploadsController中,

lib/gitlab/utils/strong_memoize.rb#L26-L32
find_model方法从params中取到id,显然并没有,所以直接return了。

app/controllers/uploads_controller.rb#L38-L42
由于authorize_create_access!的调用中直接return了并没有出现错误,所以最后会走到authorize。在该方法中直接渲染了授权后的信息,如TempPath上传路径。

app/controllers/uploads_controller.rb#L58-L60
数据在workhorse被解析

workhorse/internal/api/api.go#L258
最后解析图片执行命令造成rce

关于CSRF的防护在gitlab后端中默认对每个请求都有做,如果请求访问rails的特定接口就需要事先获取到session和csrf token。

漏洞复现
GitLab的/uploads/user接口可以上传图片且无需认证,利用poc.py脚本来测试这个漏洞:
1 | python poc.py http://your-ip:8080 "touch /tmp/success" |

进入容器内,可见touch /tmp/success已成功执行:

参考
CVE-2021-22205 GitLab RCE之未授权访问深入分析(一)-安全KER - 安全资讯平台 (anquanke.com)
https://github.com/vulhub/vulhub/blob/master/gitlab/CVE-2021-22205/README.zh-cn.md
https://devcraft.io/2021/05/04/exiftool-arbitrary-code-execution-cve-2021-22204.html