GitLab Workhorse是一个使用go语言编写的敏捷反向代理。在
gitlab_features
[6]
说明中可以总结大概的内容为,它会处理一些大的HTTP请求,比如
文件上传
、文件下载、Git push/pull和Git包下载。其它请求会反向代理到GitLab Rails应用。可以在
GitLab
[7]
的项目路径
lib/support/nginx/gitlab
中的nginx配置文件内看到其将请求转发给了GitLab Workhorse。默认采用了unix socket进行交互。
这篇文档还写到,GitLab Workhorse在实现上会起到以下作用:
理论上所有向gitlab-Rails的请求首先通过上游代理,例如 NGINX 或 Apache,然后将到达gitlab-Workhorse。
workhorse 能处理一些无需调用 Rails 组件的请求,例如静态的 js/css 资源文件,如以下的路由注册:
u.route( "" , `^/assets/` ,//匹配路由 //处理静态文件 static.ServeExisting( u.URLPrefix, staticpages.CacheExpireMax, assetsNotFoundHandler, ), withoutTracing(), // Tracing on assets is very noisy )
workhorse能修改Rails组件发来的响应。例如:假设你的Rails组件使用
send_file
,那么gitlab-workhorse将会打开磁盘中的文件然后把文件内容作为响应体返回给客户端。
gitlab-workhorse能接管向Rails组件询问操作权限后的请求,例如处理
git clone
之前得确认当前客户的权限,在向Rails组件询问确认后workhorse将继续接管
git clone
的请求,如以下的路由注册:
u.route("GET" , gitProjectPattern+`info/refs\z` , git.GetInfoRefsHandler(api)), u.route("POST" , gitProjectPattern+`git-upload-pack\z` , contentEncodingHandler(git.UploadPack(api)), withMatcher(isContentType("application/x-git-upload-pack-request" ))), u.route("POST" , gitProjectPattern+`git-receive-pack\z` , contentEncodingHandler(git.ReceivePack(api)), withMatcher(isContentType("application/x-git-receive-pack-request" ))), u.route("PUT" , gitProjectPattern+`gitlab-lfs/objects/([0-9a-f]{64})/([0-9]+)\z` , lfs.PutStore(api, signingProxy, preparers.lfs), withMatcher(isContentType("application/octet-stream" )))
workhorse 能修改发送给 Rails 组件之前的请求信息。例如:当处理 Git LFS 上传时,workhorse 首先向 Rails 组件询问当前用户是否有执行权限,然后它将请求体储存在一个临时文件里,接着它将修改过后的包含此临时文件路径的请求体发送给 Rails 组件。
workhorse 能管理与 Rails 组件通信的长时间存活的websocket连接,代码如下:
// Terminal websocket u.wsRoute(projectPattern+`-/environments/[0-9]+/terminal.ws\z` , channel.Handler(api)), u.wsRoute(projectPattern+`-/jobs/[0-9]+/terminal.ws\z` , channel.Handler(api)),
使用
ps -aux | grep "workhorse"
命令可以看到gitlab-workhorse的默认启动参数
我会简要介绍一下漏洞涉及的相关语言前置知识,这样才能够更深入的理解该漏洞,并将相关知识点串联起来,达到举一反三。
函数、方法和接口
在golang中函数和方法的定义是不同的,看下面一段代码
package main//Person接口 type Person interface { isAdult() bool }//Boy结构体 type Boy struct { Name string Age int }//函数 func NewBoy (name string , age int ) *Boy { return &Boy{ Name: name, Age: age, } }//方法 func (p *Boy) isAdult () bool { return p.Age > 18 }func main () { //结构体调用 b := NewBoy("Star" , 18 ) println (b.isAdult()) //将接口赋值b,使用接口调用 var p Person = b println (p.isAdult())//false }
其中
NewBoy
为函数,
isAdult
为方法。他们的区别是方法在func后面多了一个接收者参数,这个接受者可以是一个结构体或者接口,你可以把他当做某一个"类",而
isAdult
就是实现了该类的方法。
通过
&
取地址操作可以将一个结构体实例化,相当于
new
,可以看到在
NewBoy
中函数封装了这种操作。在main函数中通过调用
NewBoy
函数实例化Boy结构体,并调用了其方法
isAdult
。
关于接口的实现在Go语言中是隐式的。两个类型之间的实现关系不需要在代码中显式地表示出来。Go语言中没有类似于implements 的关键字。Go编译器将自动在需要的时候检查两个类型之间的实现关系。
在类型中添加与接口签名一致的方法就可以实现该方法。
如
isAdult
的参数和返回值均与接口
Person
中的方法一致。所以在main函数中可以直接将定义的接口
p
赋值为实例结构体
b
。并进行调用。
net/http
在golang中可以通过几行代码轻松实现一个http服务
package mainimport ( "net/http" "fmt" )func main () { http.HandleFunc("/" , h) http.ListenAndServe(":2333" ,nil ) }func h (w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hello world" ) }
其中的
http.HandleFunc()
是一个注册函数,用于注册路由。具体实现为绑定路径
/
和处理函数
h
的对应关系,函数
h
的类型是
(w http.ResponseWriter, r *http.Request)
。而
ListenAndServe()
函数封装了底层TCP通信的实现逻辑进行连接监听。第二个参数用于全局请求处理。如果没有传入自定义的handler。则会使用默认的
DefaultServeMux
对象处理请求最后到达
h
处理函数。
type Handler interface { ServeHTTP(ResponseWriter, *Request) }
在go中的任何结构体,只要实现了上方的
ServeHTTP
方法,也就是实现了
Handler
接口,并进行了路由注册。内部就会调用其ServeHTTP方法处理请求并返回响应。但是我们看到函数
h
并不是一个结构体方法,为什么可以处理请求呢?原来在
http.HandleFunc()
函数调用后,内部还会调用
HandlerFunc(func(ResponseWriter, *Request))
将传入的函数
h
转换为一个具有ServeHTTP方法的handler。
具体定义如下。
HandlerFunc
为一个函数类型,类型为
func(ResponseWriter, *Request)
。这个类型有一个方法为
ServeHTTP
,实现了这个方法就实现了Handler接口,
HandlerFunc
就成了一个Handler。上方的调用就是类型转换。
type HandlerFunc func (ResponseWriter, *Request) // ServeHTTP calls f(w, r). func (f HandlerFunc) ServeHTTP (w ResponseWriter, r *Request) { f(w, r) }
当调用其ServeHTTP方法时就会调用函数
h
本身。
中间件
框架中还有一个重要的功能是中间件,所谓中间件,就是连接上下级不同功能的函数或者软件。通常就是包裹函数为其提供和添加一些功能或行为。前文的
HandlerFunc
就能把签名为
func(w http.ResponseWriter, r *http.Reqeust)
的函数
h
转换成handler。这个函数也算是中间件。
了解实现概念,在具有相关基础知识前提下就可以尝试着手动进行实践,达到学以致用,融会贯通。下面就来动手实现两个中间件
LogMiddleware
和
AuthMiddleware
,一个用于日志记录的,一个用于权限校验。可以使用两种写法。
package mainimport ( "log" "net/http" "time" "encoding/json" )//权限认证中间件 type AuthMiddleware struct { Next http.Handler }//日志记录中间件 type LogMiddleware struct { Next http.Handler //这里为AuthMiddleware }//返回信息结构体 type Company struct { ID int Name string Country string }//权限认证请求处理 func (am *AuthMiddleware) ServeHTTP (w http.ResponseWriter, r *http.Request) { //如果没有嵌套中间件则使用默认的DefaultServeMux if am.Next == nil { am.Next = http.DefaultServeMux } //判断Authorization头是否不为空 auth := r.Header.Get("Authorization" ) if auth != "" { am.Next.ServeHTTP(w, r) }else { //返回401 w.WriteHeader(http.StatusUnauthorized) } }//日志请求处理 func (am *LogMiddleware) ServeHTTP (w http.ResponseWriter, r *http.Request) { if am.Next == nil { am.Next = http.DefaultServeMux } start := time.Now() //打印请求路径 log.Printf("Started %s %s" , r.Method, r.URL.Path) //调用嵌套的中间件,这里为AuthMiddleware am.Next.ServeHTTP(w, r) //打印请求耗时 log.Printf("Comleted %s in %v" , r.URL.Path, time.Since(start)) }func main () { //注册路由 http.HandleFunc("/user" , func (w http.ResponseWriter, r *http.Request) { //实例化结构体返回json格式数据 c := &Company{ ID:123 , Name:"TopSec" , Country: "CN" , } enc := json.NewEncoder(w) enc.Encode(c) }) //监听端口绑定自定义中间件 http.ListenAndServe(":8000" ,&LogMiddleware{ Next:new (AuthMiddleware), }) }
上方代码中手动声明了两个结构体
AuthMiddleware
和
LogMiddleware
,实现了handler接口的
ServeHTTP
方法。在
ListenAndServe
中通过传入结构体变量嵌套绑定了这两个中间件。
当收到请求时会首先调用
LogMiddleware
中的
ServeHTTP
方法进行日志打印,其后调用
AuthMiddleware
中的
ServeHTTP
方法进行权限认证,最后匹配路由
/user
,调用转换好的handler处理器返回JSON数据,如下图。
当权限认证失败会返回401状态码。
package mainimport ( "log" "net/http" "time" "encoding/json" )//返回信息 type Company struct { ID int Name string Country string }//权限认证中间件 func AuthHandler (next http.Handler) http .Handler { //这里使用HandlerFunc将函数包装成了httpHandler并返回给LogHandler的next return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { //如果没有嵌套中间件则使用默认的DefaultServeMux if next == nil { next = http.DefaultServeMux } //判断Authorization头是否不为空 auth := r.Header.Get("Authorization" ) if auth != "" { next.ServeHTTP(w, r) }else { //返回401 w.WriteHeader(http.StatusUnauthorized) } }) }//日志请求中间件 func LogHandler (next http.Handler) http .Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { if next == nil { next = http.DefaultServeMux } start := time.Now() //打印请求路径 log.Printf("Started %s %s" , r.Method, r.URL.Path) //调用嵌套的中间件,这里为AuthMiddleware next.ServeHTTP(w, r) //打印请求耗时 log.Printf("Comleted %s in %v" , r.URL.Path, time.Since(start)) }) }func main () { //注册路由 http.HandleFunc("/user" , func (w http.ResponseWriter, r *http.Request) { //实例化结构体返回json格式数据 c := &Company{ ID:123 , Name:"TopSec" , Country: "CN" , } enc := json.NewEncoder(w) enc.Encode(c) }) //监听端口绑定自定义中间件 http.ListenAndServe(":8000" ,LogHandler(AuthHandler(nil ))) }
写法二和写法一的区别在于写法一手动实现了
ServeHTTP
方法,而写法二使用函数的形式在其内部通过
HandlerFunc
的转换返回了一个handler处理器,这个handler实现了
ServeHTTP
方法,调用
ServeHTTP
方法则会调用其本身,所以同样也能当做中间件做请求处理。
提供两种方式的原因是当存在一个现有的类型需要转换为handler时只需要添加一个
ServeHTTP
方法即可。关于http和中间件更详细的分析就不在这里一一展开了,感兴趣的读者可以参考这两篇文章:
net/http库源码笔记
[8]
、
Go的http包详解
[9]
在ruby中当要调用方法时,可以不加括号只使用方法名。实例变量使用@开头表示。
元编程
通过元编程是可以在运行时动态地操作语言结构(如类、模块、实例变量等)
instance_variable_get(var)
方法可以取得并返回对象的实例变量var的值。
instance_variable_set(var, val)
方法可以将val的值赋值给对象实例变量var并返回该值。
instance_variable_defined(var)
方法可以判断对象实例变量var是否定义。
yield 关键字
函数调用时可以传入语句块替换其中的yield关键字并执行。如下示例:
def a return 4 end def b puts yield end b{a+1 }
调用b时会将yield关键字替换为语句块a+1,所以会调用a返回4然后加上1打印5。
Web框架rails
在rails中的路由文件一般位于
config/routes.rb
下,在路由里面可以将请求和处理方法关联起来,交给指定controller里面的action,如下形式:
post 'account/setting/:id' , to: 'account#setting' , constraints: { id: /[A-Z]\d{5 }/ }
account/setting/
是请求的固定url,
:id
表示带参数的路由。to表示交给
account
controller下的action
setting
处理。constraints定义了路由约束,使用正则表达式来对参数
:id
进行约束。
rails中可以插入定义好的类方法实现
过滤器
[10]
,一般分为
before_action
,
after_action
,
around_action
分别表示调用action"之前"、"之后"、"围绕"需要执行的操作。如:
before_action :find_product , only: [:show ]
上方表示在执行特定 Action
show
之前,先去执行 find_product 方法。
还可以使用
skip_before_action
跳过之前
before_action
指定的方法。
class ApplicationController before_action :require_login end class LoginsController skip_before_action :require_login , only: [:new , :create ]end
如在父类
ApplicationController
定义了一个
before_action
,在子类可以使用
skip_before_action
跳过,只针对于
new
和
create
的调用。
根于gitlab的
官方漏洞issues
[11]
来看,当访问接口
/uploads/user
上传图像文件时,GitLab Workhorse会将扩展名为jpg、jpeg、tiff文件传递给ExifTool。用于删除其中不合法的标签。具体的标签在
workhorse/internal/upload/exif/exif.go
中的
startProcessing
方法中有定义,为白名单处理,函数内容如下:
func (c *cleaner) startProcessing (stdin io.Reader) error { var err error //白名单标签 whitelisted_tags := []string { "-ResolutionUnit" , "-XResolution" , "-YResolution" , "-YCbCrSubSampling" , "-YCbCrPositioning" , "-BitsPerSample" , "-ImageHeight" , "-ImageWidth" , "-ImageSize" , "-Copyright" , "-CopyrightNotice" , "-Orientation" , } //传入参数 args := append ([]string {"-all=" , "--IPTC:all" , "--XMP-iptcExt:all" , "-tagsFromFile" , "@" }, whitelisted_tags...) args = append (args, "-" ) //使用CommandContext执行命令调用exiftool c.cmd = exec.CommandContext(c.ctx, "exiftool" , args...) //获取输出和错误 c.cmd.Stderr = &c.stderr c.cmd.Stdin = stdin c.stdout, err = c.cmd.StdoutPipe() if err != nil { return fmt.Errorf("failed to create stdout pipe: %v" , err) } if err = c.cmd.Start(); err != nil { return fmt.Errorf("start %v: %v" , c.cmd.Args, err) } return nil }
而ExifTool在解析文件的时候会忽略文件的扩展名,尝试根据文件的内容来确定文件类型,其中支持的类型有DjVu。
DjVu是由AT&T实验室自1996年起开发的一种图像压缩技术,已发展成为标准的图像文档格式之一
ExifTool是一个独立于平台的Perl库,一款能用作多功能图片信息查看工具。可以解析出照片的exif信息,可以编辑修改exif信息,用户能够轻松地进行查看图像文件的EXIF信息,完美支持exif信息的导出。
关键在于ExifTool在解析DjVu注释的
ParseAnt
函数中存在漏洞,所以我们就可以通过构造DjVu文件并插入恶意注释内容将其改为jpg后缀上传,因为gitlab并未在这个过程中验证文件内容是否是允许的格式,最后让ExifTool以DjVu形式来解析文件,造成了ExifTool代码执行漏洞。
该漏洞存在于ExifTool的7.44版本以上,在12.4版本中修复。Gitlab v13.10.2使用的ExifTool版本为11.70。并且接口
/uploads/user
可通过获取的X-CSRF-Token和未登录Session后来进行未授权访问。最终造成了GitLab未授权的远程代码执行漏洞。
根据官方通告得知安全版本之一有13.10.3,那么我们直接切换到分支13.10.3查看
补丁提交记录
[12]
即可,打开页面发现在4月9日和11日有两个关于本次漏洞的commits,在其后的4月13日进行了合并。
在commit
Check content type before running exiftool
中添加了
isTIFF
和
isJPEG
两个方法到
workhorse/internal/upload/rewrite.go
分别对TIFF文件解码或读取JPEG前512个字节来进行文件类型检测。
func isTIFF (r io.Reader) bool //对TIFF文件解码 _, err := tiff.Decode(r) if err == nil { return true } if _, unsupported := err.(tiff.UnsupportedError); unsupported { return true } return false }func isJPEG (r io.Reader) bool { //读取JPEG前512个字节 // Only the first 512 bytes are used to sniff the content type. buf, err := ioutil.ReadAll(io.LimitReader(r, 512 )) if err != nil { return false } return http.DetectContentType(buf) == "image/jpeg" }
在commit
Detect file MIME type before checking exif headers
中添加了方法
check_for_allowed_types
到
lib/gitlab/sanitizers/exif.rb
检测mime_type是否为JPG或TIFF。
def check_for_allowed_types (contents) mime_type = Gitlab::Utils::MimeType.from_string(contents) unless ALLOWED_MIME_TYPES.include ?(mime_type) raise "File type #{mime_type} not supported. Only supports #{ALLOWED_MIME_TYPES.join(", " )} ." end end
不过在rails中的exiftool调用是以
Rake任务
[13]
存在的。以下是rails中的rake文件,位于
lib/tasks/gitlab/uploads/sanitize.rake
namespace :gitlab do namespace :uploads do namespace :sanitize do desc 'GitLab | Uploads | Remove EXIF from images.' task :remove_exif , [:start_id , :stop_id , :dry_run , :sleep_time , :uploader , :since ] => :environment do |task, args| args.with_defaults(dry_run: 'true' ) args.with_defaults(sleep_time: 0 .3 ) logger = Logger.new(STDOUT) sanitizer = Gitlab::Sanitizers::Exif.new(logger: logger) sanitizer.batch_clean(start_id: args.start_id, stop_id: args.stop_id, dry_run: args.dry_run != 'false' , sleep_time: args.sleep_time.to_f, uploader: args.uploader, since: args.since) end end end end
Rake是一门构建语言,和make和ant很像。Rake是用Ruby写的,它支持它自己的DSL用来处理和维护
Ruby应用程序。Rails用rake的扩展来完成多种不同的任务。
网上最开始流传的方式为通过
后台上传
[14]
恶意JPG格式文件触发代码执行。从之后流出的在野利用分析来看,上传接口
/uploads/user
其实并不需要认证,也就是未授权的RCE,只需要获取到CSRF-Token和未登录session即可。该漏洞的触发流程可大概分为两种,下面将一一介绍。
本次调试由于本地GitLab Development Kit环境搭建未果,最后选择了两种不同的方式来完成本次漏洞分析的调试,关于workhorse调试环境使用gitlab官方docker配合vscode进行调试,官方docker拉取
docker run -itd \ -p 1180:80 \ -p 1122:22 \ -v /usr/local /gitlab-test/etc:/etc/gitlab \ -v /usr/local /gitlab-test/log :/var/log /gitlab \ -v /usr/local /gitlab-test/opt:/var/opt/gitlab \ --restart always \ --privileged=true \ --name gitlab-test \ gitlab/gitlab-ce:13.10.2-ce.0
运行docker后在本地使用命令
ps -aux | grep "workhorse"
可查看workhorse进程ID。
新建目录
/var/cache/omnibus/src/gitlab-rails/workhorse/
将workhorse源码复制到其下。安装vscode后打开上述目录按提示安装go全部的相关插件,然后添加调试配置,使用dlv attach模式。填入进程PID。下断点开启调试即可正常调试。
"configurations" : [ { "name" : "Attach to Process" , "type" : "go" , "request" : "attach" , "mode" : "local" , "processId" : 6257 } ]
关于rails部分的调试环境使用
gitpod
[15]
云端一键搭建的GitLab Development Kit。首先fork仓库后选择指定分支点击gitpod即可进行搭建。rails参考
pry-shell
[16]
来进行调试。在gitpod中也可以进行workhorse的调试,同样根据提示安装全部go相关插件
由于gitpod的vscode环境不是root,无法直接在其中Attach to Process进行调试,所以可以本地使用sudo起一个远程调试的环境
sudo /home/gitpod/.asdf/installs/golang/1.17.2/packages/bin/dlv-dap attach 38489 --headless --api-version=2 --log --listen=:2345
相关调试配置
"configurations" : [ { "name" : "Connect to server" , "type" : "go" , "request" : "attach" , "mode" : "remote" , "remotePath" : "${workspaceFolder}" , "port" : 2345 , "host" : "127.0.0.1" } ]
在workhorse的更新中涉及函数有
NewCleaner
,在存在漏洞的版本13.10.2中跟踪到该函数,其中调用到
startProcessing
来执行exiftool命令,具体内容可以看之前贴的代码
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 }
右键该方法浏览调用结构
从上图中除去带test字样的测试函数,可以看出最终调用点只有两个,upload包下的Handler函数
Accelerate
,和artifacts包下的Handler函数
UploadArtifacts
。现在还暂时不确定是哪个函数,根据前面的漏洞描述信息我们知道对接口
/uploads/user
的处理是整个调用链的开始,所以直接在源码中全局搜索该接口
由于请求会先经过GitLab Workhorse,我们可以直接在上图中确定位于
workhorse/internal/upstream/routes.go
路由文件中的常量
userUploadPattern
,下面搜索一下对该常量的引用