31 March, 2023

NMAP NSE Scripting By Example: Wordpress Version Detection

NMAP NSE Scripting By Example: Wordpress Version Detection
Travis Phillips
Author: Travis Phillips
Share:

In my last blog post, I gave a high-level introduction to the Nmap Scripting Engine (NSE).  In this blog post, I’d like to cover an example of writing a simple one with focus on the process around creating one.  For this example, we are going to create a simple Nmap script that will perform a simple check to attempt to obtain the version of WordPress a website is running.  This is a simple script to write and allows us to focus not only on writing it, but the entire process around it, including the thought process leading to its creation and testing it.

 

Why Would We Want to Create this Script?

Nmap overall is good at scanning ports and detecting services.  With a little extra logic applied through a NSE script, we can have Nmap process a large list of hosts at one time and return the versions of WordPress it was able to detect.  Furthermore, when coupled with the XML output format, this information can be extracted with a simple Python script.  This speeds up and automates what would otherwise be a large time sink without the risk of human error.


Examining WordPress

The first thing that needs to occur when writing a version detection script is finding a reliable way to detect the version by hand first.  WordPress has been around for a while and there are already some known methods.  One of the most common is the generator meta tag.  It would be found in the head of the page and look like one of the following:

<meta name="generator" content="WordPress x.y">
<meta name="generator" content="WordPress x.y.z">

 

Versions can be in a 2 dot version or 3 dot version in my experience, so we might want to attempt to capture both.  This solution isn’t perfect.  There are techniques to strip this meta tag out of the page, or change it like the main WordPress website does.  Below is a screenshot of the generator meta tag from https://www.wordpress.com/, which intentionally omits the version number.

www.wordpress.com modified generator meta tag.

So this method isn’t perfect, but it is good enough for the default setup of a WordPress site and we won’t let perfect be the enemy of good enough with the initial script.

 

Creating a Game Plan for How the Script Works

So before we start writing the script, we will want to make a game plan first on what it needs to do.  In this example it’s pretty straightforward:

  1. Configure the scripts portrule to trigger on all http services
  2. Make a get request for the the webroot (“/”)
  3. Examine the  HTML response and search for the generator meta tag
    1. If found, attempt to extract a 3 dot version number
    2. If that fails, attempt to extract a 2 dot version number
  4. If either version format was found, have the script return it.

 

Creating the Directory Structure

For testing purposes, I will use a Kali VM and create a folder called nmap_nse_testing and nmap_nse_testing/serve under the home directory using mkdir -p ~/nmap_nse_testing/serve.  The nmap_nse_testing script is where we will put our script for testing and the nmap_nse_testing/serve will be where we will create an index.html file to fake a WordPress page with a generator meta tag.

 

Creating a Mock-up Test WordPress Page

For the initial testing we can use the following HTML inside of ~/nmap_nse_testing/serve/index.html.

<html>
  <head>
    <title>WordPress Version Test</title>
    <meta name="generator" content="WordPress 4.7">
  </head>
  <body>
    <h1>WordPress Version Test</h1>
  </body>
</html>


This can be served via Python’s built in http.server as shown in the screenshot below.

Starting Python's http.server

Which should be accessible via a web browser when you access http://127.0.0.1:8000/.  The screenshot below shows this in the browser.

Our fake WordPress page for testing our script as seen in the browser.

Now that we have a safe target to experiment with, let’s get to work on our script!

 

Creating the NSE Script

For the development of the NSE script, we will use the ~/nmap_nse_testing directory we created earlier.  In this directory, we will create a script called http-wordpress-version.nse.  You can use Nano, VSCode, Geany, or any other text/code editor for this.  With that in mind, most do not recognize the extension.  NSE scripts are built in LUA.  If you use Geany, you can have it treat it as a LUA file by going to Document -> Set Filetype -> Scripting Languages -> Lua Source File.

 

Filling in the Head and Including the Needed Libraries

NSE scripts generally need some basic metadata to describe the script.  For this part we will populate the description, author, license, and categories values.  The table below shows how this would look in our NSE script.

description = [[
Retrieves the WordPress version from the generator meta tag if present.
]]
author = "Travis Phillips"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"safe", "discovery"}

 

For this script there are three libraries we will want to make use of: http, stdnse, and shortport. The shortport provides us with the shortport.http portrule to allow us to have the script trigger on web services.  The stdnse library we want to include for stdnse.debug() so we can have debug output in case it’s needed.  Finally, the http library provides us with the http.get() and http.response_contains() method we will use to send the HTTP GET request with.

We will use need to include each of the libraries using the following code in our script:

local http = require "http"
local stdnse = require "stdnse"
local shortport = require "shortport"

 

Creating our Portrule

Since we included the shortport library, the portrule will be easy to define.  Under the library includes we can add the following line so that our script triggers on HTTP services:

portrule = shortport.http


Now we can create the action function, which will be the main code, but first we will create a support function called get_wordpress_version_from_meta_tag that will do most of the heavy lifting for us.

 

Creating our Supporting Function

To make the main action function (the entry point of the script's execution), we will create a support function to send the HTTP GET request and parse the HTML from the response to find the version number using regular expression capture groups.  This function will return two values: found and wp_version.  If found is false, then this function failed to extract the WordPress version.  However, if it is true, then the WordPress version was extracted.

The code for this function can be found below:

function get_wordpress_version_from_meta_tag(host,port)
  -- Attempt to request the page.
  stdnse.debug(1, "Sending request for / for WordPress version meta tag")
  local response = http.get(host,port,"/")
  -- Try the 3 dot version first.
  local found, wp_version = http.response_contains(response, '<meta name="generator" content="WordPress (%d+).(%d+).(%d+)"')
  if found then
      return found, wp_version
  end
  -- Otherwise, we will try to one-shot the 2 dot version
  return http.response_contains(response, '<meta name="generator" content="WordPress (%d+).(%d+)"')
end

 

Creating our Action Function

The meat of the NSE script is the action function.  This function will call the support function we just created and if a WordPress version is found, we will report it by returning it as a string.  If nothing was found, then we will return nothing.  In the event that nothing was returned nmap will just suppress the script output entirely for that service, making tidy output.

action = function(host,port)
  -- Get WordPress Version from META Tag
  local found, wp_version = get_wordpress_version_from_meta_tag(host,port)
  if found then
    -- Check if the version number is a 3 dot format.
    if #wp_version == 3 then
      stdnse.debug(1, "WordPress version meta tag found: " .. wp_version[1] .. "." .. wp_version[2] .. "." .. wp_version[3])
      local result = wp_version[1] .. "." .. wp_version[2] .. "." .. wp_version[3]
      return result
    -- Assume it's a 2 dot format version.
    else
      stdnse.debug(1, "WordPress version meta tag found: " .. wp_version[1] .. "." .. wp_version[2])
      local result = wp_version[1] .. "." .. wp_version[2]
      return result
    end
  else
    stdnse.debug(1, "Didn't find WordPress version meta tag.")
    return
  end
end

 

Re-cap of our Script So Far

Since I covered this in sections, I want to show the completed script so far.  Note that we are still missing the NSEDoc comment, but we need to run it to get a sample of what the output looks like before we write that part.  

description = [[
Retrieves the WordPress version from the generator meta tag if present.
]]
author = "Travis Phillips"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"safe", "discovery"}

local http = require "http"
local stdnse = require "stdnse"
local shortport = require "shortport"

portrule = shortport.http

function get_wordpress_version_from_meta_tag(host,port)
  -- Attempt to request the page.
  stdnse.debug(1, "Sending request for / for WordPress version meta tag")
  local response = http.get(host,port,"/")
  -- Try the 3 dot version first.
  local found, wp_version = http.response_contains(response, '<meta name="generator" content="WordPress (%d+).(%d+).(%d+)"')
  if found then
      return found, wp_version
  end
  -- Otherwise, we will try to one-shot the 2 dot version
  return http.response_contains(response, '<meta name="generator" content="WordPress (%d+).(%d+)"')
end

action = function(host,port)
  -- Get WordPress Version from META Tag
  local found, wp_version = get_wordpress_version_from_meta_tag(host,port)
  if found then
    -- Check if the version number is a 3 dot format.
    if #wp_version == 3 then
      stdnse.debug(1, "WordPress version meta tag found: " .. wp_version[1] .. "." .. wp_version[2] .. "." .. wp_version[3])
      local result = wp_version[1] .. "." .. wp_version[2] .. "." .. wp_version[3]
      return result
    -- Assume it's a 2 dot format version.
    else
      stdnse.debug(1, "WordPress version meta tag found: " .. wp_version[1] .. "." .. wp_version[2])
      local result = wp_version[1] .. "." .. wp_version[2]
      return result
    end
  else
    stdnse.debug(1, "Didn't find WordPress version meta tag.")
    return
  end
end

 

Test Run of the Script

Now that we’ve got a script written, it’s time to take it for a test drive against our sample index.html page we are serving via Python’s http.server module.  For this we will scan just the port TCP 8000.  I’ve also decided to have it write the XML output file so we can examine what that looks like as well.  The output for the command nmap -sT -p 8000 --script=./http-wordpress-version.nse -oX nmap_test.xml 127.0.0.1 is shown in the screenshot below.

Give the NSE WordPress version detection script its test drive!


Note how under the port 8000 we see the output of our script stating it found WordPress 4.7.

 

Why The XML Output File?

We can also find that information now in our XML file!  This is awesome as you can use a large target list, have it dump it all to a XML file and search each host and port for the script node with the id of our script and grab its output attribute.  The screenshot below shows an example of the XML output from our script.

Sample output from the Nmap XML file.
As stated earlier, you can parse this with a simple Python script to extract a list of hosts, ports, and detected WordPress versions.  The Python script below is an example of how to parse an XML file for our script output.

"""
Program: parse_nmap_xml_wordpress_versions
Author: Travis Phillips
Date: 02/14/2023
Purpose: Dump a list of all hosts, ports, and wordpress versions
        detected by the http-wordpress-version.nse script in an
        Nmap XML file.
"""
import sys
import xml.etree.ElementTree as ET


def get_hostname(host: ET.Element) -> str:
   """ Get the Hostname or IP of the Nmap host node. """
   name = ""
   for addr in host.iter("address"):
       if addr.get('addr', '') and addr.get('addrtype', '') == "ipv4":
           name = addr.get('addr', '')
   for hostname in host.iter("hostname"):
       if hostname.get('name', ''):
           name = hostname.get('name', '')
   return name


def main() -> int:
   """ Main Application Logic. """
   if len(sys.argv) != 2:
       print(f" [*] Usage: {sys.argv[0]} [NMAP_XML_FILE]")
       return 1
 
   # Open the XML file and get the root.
   tree = ET.parse(sys.argv[1])
   root = tree.getroot()

   # Iterate over each host
   for host in root.iter("host"):
       # Get either the hostname or ip address for the host
       hostname = get_hostname(host)
       # Iterate over each port
       for port in host.iter("port"):
           # Extract the port number
           portid = port.get("portid", "0")
           # Iterate over each script.
           for script in port.iter("script"):
               # Check if the id of the script is ours.
               script_id =  script.get("id", "")
               if script_id == "http-wordpress-version":
                   # Print the host, port and version number.
                   wp_ver = script.get("output", "")
                   print(f" [*] {hostname}:{portid} - WordPress {wp_ver}")
   return 0


if __name__ == "__main__":
   sys.exit(main())


If we run that Python script against our Nmap XML file, we can see it works as intended, as shown in the screenshot below.

The NSE script's output extracted from the XML file via our python script
This seems boring since there was only one host in our example, but it is very nice when you need to scan thousands of hosts and generate a report off of that.

 

Adding the Script Documentation

Now that we had a successful execution, we can add the script documentation comments.  Usually at minimum, I like to include the @usage and @output comments.  The code for our script’s NSEDoc comments is shown below, which will add between the description and author values.

---
--@usage nmap --script http-wordpress-version -p 80 <target>
--
--@output
-- PORT   STATE SERVICE
-- 80/tcp open  http
-- |_http-wordpress-version: 4.7
--

 

The Completed Script

The final script is as follows:

description = [[
Retrieves the WordPress version from the generator meta tag if present.
]]

---
--@usage nmap --script http-wordpress-version -p 80 <target>
--
--@output
-- PORT   STATE SERVICE
-- 80/tcp open  http
-- |_http-wordpress-version: 4.7
--

author = "Travis Phillips"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"safe", "discovery"}

local http = require "http"
local stdnse = require "stdnse"
local shortport = require "shortport"

portrule = shortport.http

function get_wordpress_version_from_meta_tag(host,port)
  -- Attempt to request the page.
  stdnse.debug(1, "Sending request for / for WordPress version meta tag")
  local response = http.get(host,port,"/")
  -- Try the 3 dot version first.
  local found, wp_version = http.response_contains(response, '<meta name="generator" content="WordPress (%d+).(%d+).(%d+)"')
  if found then
      return found, wp_version
  end
  -- Otherwise, we will try to one-shot the 2 dot version
  return http.response_contains(response, '<meta name="generator" content="WordPress (%d+).(%d+)"')
end

action = function(host,port)
  -- Get WordPress Version from META Tag
  local found, wp_version = get_wordpress_version_from_meta_tag(host,port)
  if found then
    -- Check if the version number is a 3 dot format.
    if #wp_version == 3 then
      stdnse.debug(1, "WordPress version meta tag found: " .. wp_version[1] .. "." .. wp_version[2] .. "." .. wp_version[3])
      local result = wp_version[1] .. "." .. wp_version[2] .. "." .. wp_version[3]
      return result
    -- Assume it's a 2 dot format version.
    else
      stdnse.debug(1, "WordPress version meta tag found: " .. wp_version[1] .. "." .. wp_version[2])
      local result = wp_version[1] .. "." .. wp_version[2]
      return result
    end
  else
    stdnse.debug(1, "Didn't find WordPress version meta tag.")
    return
  end
end


You can continue to use this script from the current directory or add it to the /usr/share/nmap/scripts/ directory and use it like any other nmap NSE script.  If you add it to the /usr/share/nmap/scripts/ directory, you should also run nmap --script-updatedb as root to update Nmap’s script database so it’s aware of it and its categories.  Speaking of categories, you could also add the default category if you wanted to always run with the -A switch.

 

Conclusion

This blog covered a basic example of how to create a simple WordPress version detection script.  Hopefully, this information was useful and can help to understand the layout of NSE scripts and get started with building your own!  If you’re interested in network security fundamentals, we have a Network Security channel that covers a variety of network topics.  We also offer training via our Professionally Evil Network Testing (PENT) course and answer general basic questions in our Knowledge Center.  Finally, if you’re looking for a penetration test, professional training for your organization, or just have general security questions, please Contact Us.

Join the Professionally Evil newsletter