A custom NSE script to scan for EVM nodes (and their versions)

A custom NSE script to scan for EVM nodes (and their versions)

Contents

Originally published on Elroy’s Substack.

Never run scripts from third parties unless you trust the authors or have carefully audited the scripts yourself.

Nmap logo in ASCII art

Overview#

Recon is the first step in the cyber kill chain, it involves gathering as much information about a target. Nmap is one of the tools to do so, and NSE turns Nmap from a port scanner into an extensible security assessment.

In this article, I walk through building http-evm-rpc-info.nse—a custom Nmap Scripting Engine (NSE) script that speaks native JSON-RPC 2.0 to fingerprint EVM execution clients (Geth, Nethermind, Besu, Reth, Erigon), extract client version strings, resolve chain IDs to human-readable network names, inspect sync progress, and probe for exposed sensitive namespaces. I’ll break down the NSE architecture, share the step-by-step implementation in Lua, and highlight the non-obvious gotchas of probing blockchain nodes.

The problem#

Standard recon scans (nmap -sV) rely on generic HTTP probes (like GET / HTTP/1.0). When pointed at an Ethereum or EVM execution client listening on standard ports like 8545 (HTTP JSON-RPC) or 8551 (Engine API), Nmap usually outputs:

text
PORT     STATE SERVICE VERSION
8545/tcp open  http    Golang net/http server

This misses nearly all critical intelligence: Is this Geth, Nethermind, Besu, Erigon, or Reth?

  • What exact version and commit is running? Is it vulnerable to a known DoS vulnerability, memory leak, or consensus split?
  • Is this Ethereum Mainnet, Sepolia, Arbitrum, Optimism, Base, Polygon, or an internal private testnet?
  • Is the node actively synced with the chain tip, or lagging behind? How many peers is it connected to?
  • Did the operator accidentally leave admin_*, personal_*, or debug_* open to the world without authentication?

To turn raw port discovery into meaningful security intelligence, we need Nmap to talk to the node in its native protocol: Ethereum JSON-RPC 2.0 (EIP-1474).

What is NSE?#

From nmap.org:

The Nmap Scripting Engine (NSE) is one of Nmap’s most powerful and flexible features, it allows users to write (and share) simple scripts (using the Lua programming language ) to automate a wide variety of networking tasks.

The Anatomy of an NSE script consists of:

  1. Metadata & Documentation

    • Description Field
    • Categories Field
    • Author Field
    • License FiDependencies Field (Optional)
  2. Rules

    • prerule()
    • hostrule(host)
    • portrule(host, port)
    • postrule()
  3. Action (The Heart and where the execution logic lies)

    • Connection/Creating socket
    • Error handling ( try/catch function)
    • Connect Ident & Service
    • Get Connection Info
    • Build / Send Request
    • Parse owner
    • Close socket

If you read the Example Action Script from the nmap docs, you’ll see raw socket examples like this:

lua
action = function(host, port)
        local owner = “”

        local client_ident = nmap.new_socket()
        local client_service = nmap.new_socket()

        local catch = function()
                client_ident:close()
                client_service:close()
        end

        local try = nmap.new_try(catch)

        try(client_ident:connect(host.ip, 113))
        try(client_service:connect(host.ip, port.number))

        local localip, localport, remoteip, remoteport = try(client_service:get_info())
        local request = port.number .. “, “ .. localport .. “\r\n”

        try(client_ident:send(request))
        owner = try(client_ident:receive_lines(1))

        if string.match(owner, “ERROR”) then 
                owner = nil
        else
                owner = string.match(owner, “%d+%s*,%s*%d+%s*:%s*USERID%s*:%s*.+%s*:%s*(.+)\r?\n”)
        end

        try(client_ident:close())
        try(client_service:close())

        return owner
end

The solution#

While raw sockets are essential for low-level TCP/UDP protocols, writing HTTP/1.1 POST requests, dealing with chunked transfer encoding, handling TLS handshakes, and parsing headers manually in Lua quickly turns into a maintenance nightmare.

Instead, Nmap gives us high-level built-in modules:

  • http: Handles HTTP/HTTPS requests, custom headers, SSL negotiation, and status codes.
  • json: Parses JSON strings into Lua tables and serializes Lua tables into JSON strings.
  • shortport: Streamlines port and service matching for portrule.
  • stdnse: Provides standard logging, debugging, and structured output formatting (stdnse.format_output).

Using the anatomy described above, here is how I intend to fingerprint EVM services

EVM JSON-RPC fingerprinting workflow

Our pipeline breaks node visibility down into explicit tiers:

Tier 1: Standard RPC Probing (Level 1: Full Visibility)

  • Query web3_clientVersion, eth_chainId, eth_blockNumber, net_peerCount.
  • If the banner is returned, extract the client vendor (e.g. Geth, Nethermind, Besu, Reth, Erigon) and version string.

Tier 2: Behavioral & Namespace Heuristics (Level 2: Inferred Visibility)

  • If web3_clientVersion is disabled or stripped by a proxy, the script falls back to behavioral heuristics:

    • Error Message Syntax Fingerprinting: Sending an intentionally invalid method (probe_checkImplementation) with code -32601.
    • Vendor-Specific Namespace Probes: Checking erigon_forks, hardhat_metadata, and anvil_nodeInfo.

Restricted Endpoints (Level 4: Hardened / Restricted Visibility)

  • Handling HTTP 401 Unauthorized (Engine API on port 8551 requiring JWT auth) and HTTP 403 Forbidden (IP whitelisting or host header restrictions).

Where things get interesting#

When writing an NSE script for Web3 endpoints, several non-obvious details and edge cases come up:

  1. Why web3_clientVersion Isn’t Always Enough

    • Many node operators and infrastructure providers (Alchemy) strip the web3_clientVersion method to prevent reconnaissance.
  2. Fingerprinting via JSON-RPC Error Syntax (-32601)

    • Even when method banners are stripped, execution client implementations handle JSON-RPC method resolution differently in their internal router:

      • Geth returns error -32601 with the exact string:

        the method probe_checkImplementation does not exist

      • Besu / Nethermind / Reth / Anvil returns standard JSON-RPC string:

        Method probe_checkImplementation not found

By sending a single probe with a dummy method (probe_checkImplementation), we can infer the underlying client implementation even without a version banner!

  1. Probing Vendor-Specific Namespaces

Some execution clients and local devnets register custom RPC namespaces. For example: Erigon: erigon_forks or erigon_getHeaderByNumber, Hardhat: hardhat_metadata, Anvil (Foundry): anvil_nodeInfo.

If web3_clientVersion fails, our second Tier fires these specific lightweight probes.

  1. Handling Ethereum Hex Quantities in Lua

In the Ethereum all integer quantities (like chain IDs, block numbers, and peer counts) are returned as hex strings prefixed with 0x ("0x12543ab").

In Lua, converting these directly with tonumber() requires careful sanitization:

lua
local function parse_hex(hex_str)
  if not hex_str or type(hex_str) ~= "string" then return nil end
  local cleaned = hex_str:gsub("^0[xX]", "")
  return tonumber(cleaned, 16)
end
  1. Dynamic Port Version Updating in Nmap

Instead of just printing script output, we can update Nmap’s core service detection tables during runtime using:

lua
port.version.name = "ethereum-jsonrpc"
port.version.product = vendor or "Ethereum Client"
port.version.version = client_version
nmap.set_port_version(host, port, "hardmatched")

This ensures that Nmap’s standard service column and any downstream tools consuming Nmap XML output will properly recognize the port as ethereum-jsonrpc rather than generic http.


Security takeaways (from me)#

For Pentesters & Bug Bounty Hunters

  • Quickly spotting chain 31337 (Anvil/Hardhat) or 1337 (Geth Devnet) exposed to the public internet usually means pre-funded test accounts with known private keys.
  • Exact version strings allow immediate cross-referencing against known client consensus splits, memory exhaustion bugs, or RPC DoS CVEs.
  • If HTTP RPC is locked down with JWT (HTTP 401) or IP whitelisting (HTTP 403), pivot recon to the P2P networking layer (Port 30303 / DevP2P) using eth/68 hello handshakes to extract client version and fork hashes directly.

For Node Operators & Infrastructure Teams

  • Never expose unauthenticated JSON-RPC directly to 0.0.0.0. Use VPC internal networking or authenticated reverse proxies.
  • Strictly whitelist required APIs (e.g. --http.api eth,net,web3) and never expose admin_*, personal_*, or development namespaces.
  • Always ensure consensus client <-> execution client communication uses a strong jwtsecret.

Wen update?#

Building http-evm-rpc-info.nse bridges the gap between traditional network recon and Web3 infrastructure. In future iterations, I’m planning to expand this with

  1. P2P DevP2P Layer Scanner (Port 30303)where an NSE script performs RLPx handshakes to identify nodes even when HTTP RPC is completely disabled.
  2. WebSocket Subscription Auditing (Port 8546): Testing WS connections for unauthorized event filtering and mempool eavesdropping.

For the full .nse script, please check out my repo 🥹🥹🥹

Signing off!
Elroy

© 2026 tayelroy. Some rights reserved.

built 4e82bf7 · 2026-11-08