<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dipesh Thapa</title>
    <description>The latest articles on DEV Community by Dipesh Thapa (@dipesthapa).</description>
    <link>https://dev.to/dipesthapa</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4018392%2F9b81c2f5-abb2-49b4-b53c-2c3a8e7d3a8b.png</url>
      <title>DEV Community: Dipesh Thapa</title>
      <link>https://dev.to/dipesthapa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dipesthapa"/>
    <language>en</language>
    <item>
      <title>Hardening my own Nmap web UI: the security holes I shipped, and what actually saved me</title>
      <dc:creator>Dipesh Thapa</dc:creator>
      <pubDate>Mon, 06 Jul 2026 21:34:18 +0000</pubDate>
      <link>https://dev.to/dipesthapa/i-shipped-an-unauthenticated-rce-in-my-own-port-scanner-heres-the-whole-chain-and-how-i-killed-35kl</link>
      <guid>https://dev.to/dipesthapa/i-shipped-an-unauthenticated-rce-in-my-own-port-scanner-heres-the-whole-chain-and-how-i-killed-35kl</guid>
      <description>&lt;p&gt;I built a web front end for an Nmap-based port scanner: a FastAPI backend, a React&lt;br&gt;
dashboard, background scan jobs, a plugin system. It worked. Then I sat down and&lt;br&gt;
audited it like an attacker would — and found a stack of real weaknesses, plus a&lt;br&gt;
lesson in &lt;em&gt;why you verify an exploit before you call it one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the honest version: the holes I found, the unauthenticated-RCE chain I&lt;br&gt;
&lt;em&gt;thought&lt;/em&gt; I had, why it didn't actually fire, and the hardening I shipped anyway.&lt;/p&gt;

&lt;p&gt;Repo: &lt;strong&gt;&lt;a href="https://github.com/DipesThapa/PortScanner" rel="noopener noreferrer"&gt;https://github.com/DipesThapa/PortScanner&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is my own project, audited and fixed by me. No third-party systems were&lt;br&gt;
touched. Scanners are dual-use — only ever point one at hosts you own or are&lt;br&gt;
authorised to test.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Hole 1: no authentication, anywhere
&lt;/h2&gt;

&lt;p&gt;The foundation: &lt;strong&gt;every&lt;/strong&gt; API route and the &lt;code&gt;/ws/status&lt;/code&gt; WebSocket were open. No&lt;br&gt;
API key, no session. The Dockerfile bound &lt;code&gt;0.0.0.0:8000&lt;/code&gt; and ran as root. Anyone&lt;br&gt;
who could reach the port could drive scans, hit the upload endpoint, and read&lt;br&gt;
every job's logs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;api_router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;APIRouter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# no dependencies — fully open
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the real, unambiguous problem. Everything below is only interesting&lt;br&gt;
&lt;em&gt;because&lt;/em&gt; it sat behind no auth.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hole 2: an upload endpoint that allowlisted its own files
&lt;/h2&gt;

&lt;p&gt;Deep-dive follow-up commands ran against an allowlist — good instinct. But an&lt;br&gt;
upload endpoint wrote a file, &lt;code&gt;chmod +x&lt;/code&gt;'d it, and then added it to that same&lt;br&gt;
allowlist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scripts_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;   &lt;span class="c1"&gt;# upload authorises itself
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An allowlist any input can extend isn't an allowlist. This is a genuine design&lt;br&gt;
footgun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole 3: the RCE I &lt;em&gt;thought&lt;/em&gt; I had — and why it didn't fire
&lt;/h2&gt;

&lt;p&gt;Here's the chain I got excited about: the scan target flows toward Nmap's argv,&lt;br&gt;
and it's &lt;code&gt;subprocess.run(..., shell=False)&lt;/code&gt;. No shell injection — but you don't&lt;br&gt;
need a shell to abuse Nmap. If a target became &lt;code&gt;--script=/uploaded.nse&lt;/code&gt;, Nmap&lt;br&gt;
would load and run that NSE (Lua) script, and NSE can call &lt;code&gt;os.execute&lt;/code&gt;. Upload a&lt;br&gt;
malicious &lt;code&gt;.nse&lt;/code&gt; (Hole 2), get Nmap to load it (target-as-flag), done. Textbook&lt;br&gt;
unauthenticated RCE.&lt;/p&gt;

&lt;p&gt;Except when I actually tested it, &lt;strong&gt;it didn't work&lt;/strong&gt; — and the reasons are the&lt;br&gt;
interesting part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;argparse&lt;/code&gt; blocks it.&lt;/strong&gt; The API doesn't call Nmap directly; it builds CLI
args and passes them through the CLI's &lt;code&gt;argparse&lt;/code&gt;. A flag-shaped value in the
two-token form the API uses — &lt;code&gt;["--target", "--script=/evil.nse"]&lt;/code&gt; — makes
argparse error with &lt;em&gt;"expected one argument"&lt;/em&gt;. The flag never reaches Nmap.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_args&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--target&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--script=/tmp/evil.nse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
   &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;argument&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="n"&gt;one&lt;/span&gt; &lt;span class="n"&gt;argument&lt;/span&gt;   &lt;span class="c1"&gt;# rejected
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A second gate blocks the upload path.&lt;/strong&gt; The deep-dive endpoint only runs
commands that appear in the plugin's generated &lt;code&gt;available_cmds&lt;/code&gt; (fixed
templates for &lt;code&gt;nmap&lt;/code&gt;/&lt;code&gt;nuclei&lt;/code&gt;/&lt;code&gt;testssl.sh&lt;/code&gt;). An uploaded script's path never
lands in that set, so even though it's allowlisted, you can't invoke it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the "RCE" was &lt;strong&gt;latent, not proven&lt;/strong&gt; — two accidental guardrails stood between&lt;br&gt;
a genuinely bad design and actual code execution. That's worth saying plainly:&lt;br&gt;
finding scary-looking primitives is easy; confirming they chain into a working&lt;br&gt;
exploit is the actual work, and here they didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole 4: unsafe XML parsing (a real code smell)
&lt;/h2&gt;

&lt;p&gt;Bandit flagged the Nmap-XML parser using &lt;code&gt;xml.etree.ElementTree.fromstring&lt;/code&gt;&lt;br&gt;
(B314) — vulnerable to XXE / entity expansion if the XML is untrusted. In the&lt;br&gt;
normal scan flow Nmap generates and escapes its own XML, so it's hard to reach in&lt;br&gt;
practice — but the moment you parse &lt;em&gt;user-supplied&lt;/em&gt; XML (offline re-parsing, an&lt;br&gt;
import feature), it's a real hole. Cheap to fix, so fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hardening I shipped
&lt;/h2&gt;

&lt;p&gt;Even though the RCE wasn't exploitable as-shipped, every weakness was worth&lt;br&gt;
closing — secure-by-design beats "technically blocked by an accident":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth on everything.&lt;/strong&gt; &lt;code&gt;X-API-Key&lt;/code&gt; dependency on the whole API router; the
WebSocket validates a token. Key from &lt;code&gt;PORTSCANNER_API_KEY&lt;/code&gt; or generated to
&lt;code&gt;web_runs/.api_key&lt;/code&gt; (0600) on first boot — never anonymous. Constant-time compare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input validation + defence in depth.&lt;/strong&gt; Targets must match a hostname/IP/CIDR
pattern and can't start with &lt;code&gt;-&lt;/code&gt;; dangerous flags (&lt;code&gt;--script&lt;/code&gt;, &lt;code&gt;-oN&lt;/code&gt;, …) are
rejected; and a &lt;code&gt;--&lt;/code&gt; sentinel goes before the target so Nmap stops parsing
options regardless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uploads no longer self-authorise&lt;/strong&gt;, and upload is disabled unless an operator
opts in with &lt;code&gt;PORTSCANNER_ENABLE_SCRIPT_UPLOAD=1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;defusedxml&lt;/code&gt;&lt;/strong&gt; for all Nmap-XML parsing.&lt;/li&gt;
&lt;li&gt;Secret redaction before logs are persisted; non-root container with
&lt;code&gt;cap_net_raw&lt;/code&gt; scoped to the Nmap binary; pinned deps. Locked in with a
&lt;code&gt;tests/test_security.py&lt;/code&gt; suite and CI: pytest, Bandit, pip-audit, CodeQL.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Five things worth stealing
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Auth is link zero.&lt;/strong&gt; Everything else only matters relative to "who can reach it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the exploit before you name it.&lt;/strong&gt; A scary primitive isn't a
vulnerability until you've walked it end to end. Mine died at &lt;code&gt;argparse&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shell=False&lt;/code&gt; ≠ safe&lt;/strong&gt; — the invoked program still parses its own args. Put
&lt;code&gt;--&lt;/code&gt; before positionals and validate anything flag-shaped anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An allowlist inputs can extend isn't one.&lt;/strong&gt; Don't let the thing being
authorised share a writer with the authoriser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the scanners.&lt;/strong&gt; Bandit/CodeQL/pip-audit in CI caught the XML issue my
manual review skimmed past.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full before/after, threat model, and CI are in the repo. If I got something&lt;br&gt;
wrong, open an issue — publishing the honest version is the whole point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/DipesThapa/PortScanner" rel="noopener noreferrer"&gt;https://github.com/DipesThapa/PortScanner&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>fastapi</category>
      <category>appsec</category>
    </item>
  </channel>
</rss>
