From 0a3bf95897f3ac0ce6032c8a664e07df05fab357 Mon Sep 17 00:00:00 2001 From: patrik Date: Fri, 17 Jun 2011 06:12:01 +0000 Subject: [PATCH] o [NSE] Added a MySQL audit script and a rulebase that supports auditing a subset of the MySQL CIS 1.0.2 Benchmark. [Patrik] --- CHANGELOG | 3 + nselib/data/mysql-cis.audit | 213 ++++++++++++++++++++++++++++++++++++ scripts/mysql-audit.nse | 170 ++++++++++++++++++++++++++++ scripts/script.db | 1 + 4 files changed, 387 insertions(+) create mode 100644 nselib/data/mysql-cis.audit create mode 100644 scripts/mysql-audit.nse diff --git a/CHANGELOG b/CHANGELOG index cfdafa81e..b61544210 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,8 @@ # Nmap Changelog ($Id$); -*-text-*- +o [NSE] Added a MySQL audit script and a rulebase that supports auditing a + subset of the MySQL CIS 1.0.2 Benchmark. [Patrik] + o [NSE] Added ipv6 support to the wsdd, dnssd and upnp libraries. Applied patch from Dan Miller that fixes errors in processing and sorting ipv6 addresses in scripts using these libraries. [Daniel Miller, Patrik] diff --git a/nselib/data/mysql-cis.audit b/nselib/data/mysql-cis.audit new file mode 100644 index 000000000..0645fe0bb --- /dev/null +++ b/nselib/data/mysql-cis.audit @@ -0,0 +1,213 @@ +require("tab") + +TEMPLATE_NAME="CIS MySQL Benchmarks v1.0.2" + +-- These accounts should be treated as admins and excluded from some of the results +local ADMIN_ACCOUNTS={"root", "debian-sys-maint"} + +-- Checks whether a resultset is empty or not +local function isEmpty(rows) + if ( #rows > 0 ) then return false end + return true +end + +-- Extracts a column from a row and return all occurances as an array +local function col2tab(rows, cname) + local tab = {} + for _, row in ipairs(rows) do table.insert(tab, row[cname]) end + return tab +end + +local function createINstmt(tab) + local tab2 = {} + for i=1, #tab do tab2[i] = ("'%s'"):format(tab[i]) end + return stdnse.strjoin(",", tab2) +end + + +-- This next section contains all the tests + +-- Logging +test { id="3.1", desc="Skip symbolic links", sql="SHOW variables WHERE Variable_name = 'log_error' AND Value IS NOT NULL", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} + +test { id="3.2", desc="Logs not on system partition", sql="SHOW variables WHERE Variable_name = 'log_bin' AND Value <> 'OFF'", check=function(rowstab) + local log = col2tab(rowstab[1], 'Value') + return { status = isEmpty(rowstab[1]), result = log, review = not(isEmpty(rowstab[1])) } +end +} + +test { id="3.2", desc="Logs not on database partition", sql="SHOW variables WHERE Variable_name = 'log_bin' AND Value <> 'OFF'", check=function(rowstab) + local log = col2tab(rowstab[1], 'Value') + return { status = isEmpty(rowstab[1]), result = log, review = not(isEmpty(rowstab[1])) } +end +} + + +-- General +test { id="4.1", desc="Supported version of MySQL", sql="SHOW VARIABLES like 'version'", check=function(rowstab) + local ver = col2tab(rowstab[1], 'Value')[1] + return { status = true, review = true, result = ("Version: %s"):format(ver) } +end +} + +test { id="4.4", desc="Remove test database", sql="SHOW DATABASES like 'test'", check=function(rowstab) return { status = isEmpty(rowstab[1]) } end } + +test { id="4.5", desc="Change admin account name", sql="SELECT user FROM mysql.user WHERE user='root';", check=function(rowstab) return { status = isEmpty(rowstab[1]) } end } + +test { id="4.7", desc="Verify Secure Password Hashes", sql="SELECT DISTINCT user, password from mysql.user where length(password) < 41 AND length(password) > 0", check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found having weak password hashes" + return { status = isEmpty(rowstab[1]), result = users } +end +} + +test { id="4.9", desc="Wildcards in user hostname", sql="select user from mysql.user where host = '%'", check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found with wildcards in hostname" + return { status = isEmpty(rowstab[1]), result = users } +end +} + +test { id="4.10", desc="No blank passwords", sql="select distinct user, password from mysql.user where length(password) = 0 or password is null", check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found having blank/empty passwords" + return { status = isEmpty(rowstab[1]), result = users } +end +} + +test { id="4.11", desc="Anonymous account", sql="select distinct user from mysql.user where user =''", check=function(rowstab) return { status = isEmpty(rowstab[1]) } end } + + +-- MySQL Permissions +test { id="5.1", desc="Access to mysql database", + sql = { "SELECT user, host FROM mysql.db WHERE db = 'mysql' and ((Select_priv = 'Y') or (Insert_priv = 'Y') " .. + "or (Update_priv = 'Y') or (Delete_priv = 'Y') or (Create_priv = 'Y') or (Drop_priv = 'Y'))", + "SELECT user, host FROM mysql.user WHERE (Select_priv = 'Y') or (Insert_priv = 'Y') or " .. + "(Update_priv = 'Y') or (Delete_priv = 'Y') or (Create_priv = 'Y') or (Drop_priv = 'Y')" }, + check = function(rowstab) + + local result = tab.new(2) + tab.addrow(result, "user", "host") + + for _, rows in ipairs(rowstab) do + for _, row in ipairs(rows) do + tab.addrow( result, row.user, row.host ) + end + end + + return { status = false, review = true, result = { tab.dump(result), name="Verify the following users that have access to the MySQL database" } } + end +} + +test { id="5.2", desc="Do not grant FILE privileges to non Admin users", + sql=("SELECT user, host FROM mysql.user WHERE File_priv = 'Y' AND user NOT IN (%s)"):format(createINstmt(ADMIN_ACCOUNTS)), + check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found having the FILE privilege" + return { status = isEmpty(rowstab[1]), result = users, review = not(isEmpty(rowstab[1])) } + end +} + +test { id="5.3", desc="Do not grant PROCESS privileges to non Admin users", + sql=("SELECT user, host FROM mysql.user WHERE Process_priv = 'Y' AND user NOT IN (%s)"):format(createINstmt(ADMIN_ACCOUNTS)), + check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found having the PROCESS privilege" + return { status = isEmpty(rowstab[1]), result = users, review = not(isEmpty(rowstab[1])) } + end +} + +test { id="5.4", desc="Do not grant SUPER privileges to non Admin users", + sql=("SELECT user, host FROM mysql.user WHERE Super_priv = 'Y' AND user NOT IN (%s)"):format(createINstmt(ADMIN_ACCOUNTS)), + check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found having the SUPER privilege" + return { status = isEmpty(rowstab[1]), result = users, review = not(isEmpty(rowstab[1])) } + end +} + +test { id="5.5", desc="Do not grant SHUTDOWN privileges to non Admin users", + sql=("SELECT user, host FROM mysql.user WHERE Shutdown_priv = 'Y' AND user NOT IN (%s)"):format(createINstmt(ADMIN_ACCOUNTS)), + check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found having the SHUTDOWN privilege" + return { status = isEmpty(rowstab[1]), result = users, review = not(isEmpty(rowstab[1])) } + end +} + +test { id="5.6", desc="Do not grant CREATE USER privileges to non Admin users", + sql=("SELECT user, host FROM mysql.user WHERE Create_user_priv = 'Y' AND user NOT IN (%s)"):format(createINstmt(ADMIN_ACCOUNTS)), + check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found having the CREATE USER privilege" + return { status = isEmpty(rowstab[1]), result = users, review = not(isEmpty(rowstab[1])) } + end +} + +test { id="5.7", desc="Do not grant RELOAD privileges to non Admin users", + sql=("SELECT user, host FROM mysql.user WHERE Reload_priv = 'Y' AND user NOT IN (%s)"):format(createINstmt(ADMIN_ACCOUNTS)), + check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found having the RELOAD privilege" + return { status = isEmpty(rowstab[1]), result = users, review = not(isEmpty(rowstab[1])) } + end +} + +test { id="5.8", desc="Do not grant GRANT privileges to non Admin users", + sql=("SELECT user, host FROM mysql.user WHERE Grant_priv = 'Y' AND user NOT IN (%s)"):format(createINstmt(ADMIN_ACCOUNTS)), + check=function(rowstab) + local users = col2tab(rowstab[1], 'user') + users.name = ( #users > 0 ) and "The following users were found having the GRANT privilege" + return { status = isEmpty(rowstab[1]), result = users, review = not(isEmpty(rowstab[1])) } + end +} + + +-- MySQL Configuraiton options +test { id="6.2", desc="Disable Load data local", sql="SHOW variables WHERE Variable_name = 'local_infile' AND Value='OFF'", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} + +test { id="6.3", desc="Disable old password hashing", sql="SHOW variables WHERE Variable_name = 'old_passwords' AND Value='OFF'", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} + +test { id="6.4", desc="Safe show database", sql="SHOW variables WHERE Variable_name = 'safe_show_database' AND Value='ON'", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} + +test { id="6.5", desc="Secure auth", sql="SHOW variables WHERE Variable_name = 'secure_auth' AND Value='ON'", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} + +test { id="6.6", desc="Grant tables", sql="SHOW variables WHERE Variable_name = 'skip_grant_tables' AND Value='OFF'", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} + +test { id="6.7", desc="Skip merge", sql="SHOW variables WHERE Variable_name = 'have_merge_engine' AND Value='DISABLED'", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} + +test { id="6.8", desc="Skip networking", sql="SHOW variables WHERE Variable_name = 'skip_networking' AND Value='ON'", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} + +test { id="6.9", desc="Safe user create", sql="select @@global.sql_mode, @@session.sql_mode FROM dual WHERE @@session.sql_mode='NO_AUTO_CREATE_USER' AND @@global.sql_mode='NO_AUTO_CREATE_USER'", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} + +test { id="6.10", desc="Skip symbolic links", sql="SHOW variables WHERE Variable_name = 'have_symlink' AND Value='DISABLED'", check=function(rowstab) + return { status = not(isEmpty(rowstab[1])) } +end +} diff --git a/scripts/mysql-audit.nse b/scripts/mysql-audit.nse new file mode 100644 index 000000000..6c0de00fe --- /dev/null +++ b/scripts/mysql-audit.nse @@ -0,0 +1,170 @@ +description = [[ +Audit MySQL database server +]] + + +--- +-- @usage +-- nmap -p 3306 --script mysql-audit --script-args "mysql-audit.username='root', \ +-- mysql-audit.password='foobar',mysql-audit.filename='nselib/data/mysql-cis.audit'" +-- +-- @args mysql-audit.username the username with which to connect to the database +-- @args mysql-audit.password the password with which to connect to the database +-- @args mysql-audit.filename the name of the file containing the audit rulebase +-- +-- @output +-- PORT STATE SERVICE +-- 3306/tcp open mysql +-- | mysql-audit: +-- | CIS MySQL Benchmarks v1.0.2 +-- | 3.1: Skip symbolic links => PASS +-- | 3.2: Logs not on system partition => PASS +-- | 3.2: Logs not on database partition => PASS +-- | 4.1: Supported version of MySQL => REVIEW +-- | Version: 5.1.54-1ubuntu4 +-- | 4.4: Remove test database => PASS +-- | 4.5: Change admin account name => FAIL +-- | 4.7: Verify Secure Password Hashes => PASS +-- | 4.9: Wildcards in user hostname => FAIL +-- | The following users were found with wildcards in hostname +-- | root +-- | super +-- | super2 +-- | 4.10: No blank passwords => PASS +-- | 4.11: Anonymous account => PASS +-- | 5.1: Access to mysql database => REVIEW +-- | Verify the following users that have access to the MySQL database +-- | user host +-- | root localhost +-- | root patrik-11 +-- | root 127.0.0.1 +-- | debian-sys-maint localhost +-- | root % +-- | super % +-- | 5.2: Do not grant FILE privileges to non Admin users => REVIEW +-- | The following users were found having the FILE privilege +-- | super +-- | super2 +-- | 5.3: Do not grant PROCESS privileges to non Admin users => REVIEW +-- | The following users were found having the PROCESS privilege +-- | super +-- | 5.4: Do not grant SUPER privileges to non Admin users => REVIEW +-- | The following users were found having the SUPER privilege +-- | super +-- | 5.5: Do not grant SHUTDOWN privileges to non Admin users => REVIEW +-- | The following users were found having the SHUTDOWN privilege +-- | super +-- | 5.6: Do not grant CREATE USER privileges to non Admin users => REVIEW +-- | The following users were found having the CREATE USER privilege +-- | super +-- | 5.7: Do not grant RELOAD privileges to non Admin users => REVIEW +-- | The following users were found having the RELOAD privilege +-- | super +-- | 5.8: Do not grant GRANT privileges to non Admin users => PASS +-- | 6.2: Disable Load data local => FAIL +-- | 6.3: Disable old password hashing => PASS +-- | 6.4: Safe show database => FAIL +-- | 6.5: Secure auth => FAIL +-- | 6.6: Grant tables => FAIL +-- | 6.7: Skip merge => FAIL +-- | 6.8: Skip networking => FAIL +-- | 6.9: Safe user create => FAIL +-- | 6.10: Skip symbolic links => FAIL +-- | +-- |_ The audit was performed using the db-account: root + +-- Version 0.1 +-- Created 05/29/2011 - v0.1 - created by Patrik Karlsson + +author = "Patrik Karlsson" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"discovery", "safe"} + +require 'shortport' +require 'mysql' + +portrule = shortport.port_or_service(3306, "mysql") +local TEMPLATE_NAME = "" + +local function loadAuditRulebase( filename ) + + local file, err = loadfile(filename) + local rules = {} + + if ( not(file) ) then + return false, ("ERROR: Failed to load rulebase:\n%s"):format(err) + end + + setfenv(file, setmetatable({ + test = function(t) table.insert(rules, t) end; + }, {__index = _G})) + + file() + TEMPLATE_NAME = getfenv(file)["TEMPLATE_NAME"] + return true, rules +end + +action = function( host, port ) + + local username = stdnse.get_script_args("mysql-audit.username") + local password = stdnse.get_script_args("mysql-audit.password") + local filename = stdnse.get_script_args("mysql-audit.filename") + + if ( not(filename) ) then + return "\n No audit rulebase file was supplied (see mysql-audit.filename)" + end + + if ( not(username) ) then + return "\n No username was supplied (see mysql-audit.username)" + end + + local status, tests = loadAuditRulebase( filename ) + if( not(status) ) then return rules end + + local socket = nmap.new_socket() + status = socket:connect(host, port) + + local response + status, response = mysql.receiveGreeting( socket ) + if ( not(status) ) then return response end + + status, response = mysql.loginRequest( socket, { authversion = "post41", charset = response.charset }, username, password, response.salt ) + + if ( not(status) ) then return "ERROR: Failed to authenticate" end + local results = {} + + for _, test in ipairs(tests) do + local queries = ( "string" == type(test.sql) ) and { test.sql } or test.sql + local rowstab = {} + + for _, query in ipairs(queries) do + local row + status, row = mysql.sqlQuery( socket, query ) + if ( not(status) ) then + table.insert( results, { ("%s: ERROR: Failed to execute SQL statement"):format(test.id) } ) + else + table.insert(rowstab, row) + end + end + + if ( #rowstab > 0 ) then + local result_part = {} + local res = test.check(rowstab) + local status, data = res.status, res.result + status = ( res.review and "REVIEW" ) or (status and "PASS" or "FAIL") + + table.insert( result_part, ("%s: %s => %s"):format(test.id, test.desc, status) ) + if ( data ) then + table.insert(result_part, { data } ) + end + table.insert( results, result_part ) + end + end + + socket:close() + results.name = TEMPLATE_NAME + + table.insert(results, {"", ("The audit was performed using the db-account: %s"):format(username)}) + + return stdnse.format_output(true, { results }) +end \ No newline at end of file diff --git a/scripts/script.db b/scripts/script.db index 9d1bad5d2..2a3c8b49a 100644 --- a/scripts/script.db +++ b/scripts/script.db @@ -107,6 +107,7 @@ Entry { filename = "ms-sql-info.nse", categories = { "default", "discovery", "sa Entry { filename = "ms-sql-query.nse", categories = { "discovery", "safe", } } Entry { filename = "ms-sql-tables.nse", categories = { "discovery", "safe", } } Entry { filename = "ms-sql-xp-cmdshell.nse", categories = { "intrusive", } } +Entry { filename = "mysql-audit.nse", categories = { "discovery", "safe", } } Entry { filename = "mysql-brute.nse", categories = { "auth", "intrusive", } } Entry { filename = "mysql-databases.nse", categories = { "discovery", "intrusive", } } Entry { filename = "mysql-empty-password.nse", categories = { "auth", "intrusive", } }