Originally published on Elroy’s Substack.
Never run scripts from third parties unless you trust the authors or have carefully audited the scripts yourself.

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:
PORT STATE SERVICE VERSION
8545/tcp open http Golang net/http serverThis 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_*, ordebug_*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:
-
Metadata & Documentation
- Description Field
- Categories Field
- Author Field
- License FiDependencies Field (Optional)
-
Rules
- prerule()
- hostrule(host)
- portrule(host, port)
- postrule()
-
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:
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
endThe 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 forportrule.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

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_clientVersionis 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, andanvil_nodeInfo.
- Error Message Syntax Fingerprinting: Sending an intentionally invalid method (
Restricted Endpoints (Level 4: Hardened / Restricted Visibility)
- Handling HTTP
401 Unauthorized(Engine API on port8551requiring JWT auth) and HTTP403 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:
-
Why
web3_clientVersionIsn’t Always Enough- Many node operators and infrastructure providers (Alchemy) strip the
web3_clientVersionmethod to prevent reconnaissance.
- Many node operators and infrastructure providers (Alchemy) strip the
-
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
-32601with 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!
- 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.
- 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:
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- Dynamic Port Version Updating in Nmap
Instead of just printing script output, we can update Nmap’s core service detection tables during runtime using:
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) or1337(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) usingeth/68hello 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 exposeadmin_*,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
- P2P DevP2P Layer Scanner (Port 30303)where an NSE script performs RLPx handshakes to identify nodes even when HTTP RPC is completely disabled.
- 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