--- -- Simple MySQL Library supporting a very limited subset of operations. -- -- https://dev.mysql.com/doc/internals/en/client-server-protocol.html -- -- @copyright Same as Nmap--See https://nmap.org/book/man-legal.html -- -- @author Patrik Karlsson local nmap = require "nmap" local stdnse = require "stdnse" local string = require "string" local table = require "table" local math = require "math" _ENV = stdnse.module("mysql", stdnse.seeall) -- Version 0.3 -- -- Created 01/15/2010 - v0.1 - created by Patrik Karlsson -- Revised 01/23/2010 - v0.2 - added query support, cleanup, documentation -- Revised 08/24/2010 - v0.3 - added error handling for receiveGreeting -- fixed a number of incorrect receives and changed -- them to receive_bytes instead. local tab = require('tab') local HAVE_SSL, openssl = pcall(require,'openssl') Capabilities = { LongPassword = 0x1, FoundRows = 0x2, LongColumnFlag = 0x4, ConnectWithDatabase = 0x8, DontAllowDatabaseTableColumn = 0x10, SupportsCompression = 0x20, ODBCClient = 0x40, SupportsLoadDataLocal = 0x80, IgnoreSpaceBeforeParenthesis = 0x100, Speaks41ProtocolNew = 0x200, InteractiveClient = 0x400, SwitchToSSLAfterHandshake = 0x800, IgnoreSigpipes = 0x1000, SupportsTransactions = 0x2000, Speaks41ProtocolOld = 0x4000, Support41Auth = 0x8000 } ExtCapabilities = { SupportsMultipleStatments = 0x1, SupportsMultipleResults = 0x2, SupportsAuthPlugins = 0x8, } Charset = { latin1_COLLATE_latin1_swedish_ci = 0x8 } ServerStatus = { InTransaction = 0x1, AutoCommit = 0x2, MoreResults = 0x4, MultiQuery = 0x8, BadIndexUsed = 0x10, NoIndexUsed = 0x20, CursorExists = 0x40, LastRowSebd = 0x80, DatabaseDropped = 0x100, NoBackslashEscapes = 0x200 } Command = { Query = 3 } local MAXPACKET = 16777216 local HEADER_SIZE = 4 --- Parses a MySQL header -- -- @param data string of raw data -- @return response table containing the fields len and packetno local function decodeHeader( data, pos ) local response = {} local pos, tmp = pos or 1, 0 tmp, pos = string.unpack( "> 24 ) return pos, response end --- Receives the server greeting upon initial connection -- -- @param socket already connected to the remote server -- @return status true on success, false on failure -- @return response table with the following fields proto, version, -- threadid, salt, capabilities, charset and -- status or error message on failure (status == false) function receiveGreeting( socket ) local catch = function() socket:close() stdnse.debug1("receiveGreeting(): failed") end local try = nmap.new_try(catch) local data = try( socket:receive_bytes(HEADER_SIZE) ) local pos, response, tmp, _ pos, response = decodeHeader( data, 1 ) -- do we need to read the remainder if ( #data - HEADER_SIZE < response.len ) then local tmp = try( socket:receive_bytes( response.len - #data + HEADER_SIZE ) ) data = data .. tmp end local is_error is_error, pos = string.unpack("B", data, pos) if ( is_error == 0xff ) then response.errorcode, pos = string.unpack( " 0 then tmp, pos = string.unpack("c" .. (math.max(13, auth_plugin_len - 8) - 1) .. "x", data, pos) response.salt = response.salt .. tmp end if response.extcapabilities & ExtCapabilities.SupportsAuthPlugins > 0 then response.auth_plugin_name = string.unpack("z", data, pos) end end elseif response.proto == 9 then response.auth_plugin_data, pos = string.unpack( "z", data, pos ) else stdnse.debug2("Unknown MySQL protocol version: %d", response.proto) end response.errorcode = 0 return true, response end --- Creates a hashed value of the password and salt according to MySQL authentication post version 4.1 -- -- @param pass string containing the users password -- @param salt string containing the servers salt as obtained from receiveGreeting -- @return reply string containing the raw hashed value local function createLoginHash(pass, salt) local hash_stage1 local hash_stage2 local hash_stage3 local reply = {} local pos, b1, b2, b3, _ = 1, 0, 0, 0 if ( not(HAVE_SSL) ) then return nil end hash_stage1 = openssl.sha1( pass ) hash_stage2 = openssl.sha1( hash_stage1 ) hash_stage3 = openssl.sha1( salt .. hash_stage2 ) for pos=1, hash_stage1:len() do b1 = string.unpack( "B", hash_stage1, pos ) b2 = string.unpack( "B", hash_stage3, pos ) reply[pos] = string.char( b2 ~ b1 ) end return table.concat(reply) end --- Attempts to Login to the remote mysql server -- -- @param socket already connected to the remote server -- @param params table with additional options to the loginrequest -- current supported fields are charset and authversion -- authversion is either "pre41" or "post41" (default is post41) -- currently only post41 authentication is supported -- @param username string containing the username of the user that is authenticating -- @param password string containing the users password or nil if empty -- @param salt string containing the servers salt as received from receiveGreeting -- @return status boolean -- @return response table or error message on failure function loginRequest( socket, params, username, password, salt ) local catch = function() socket:close() stdnse.debug1("loginRequest(): failed") end local try = nmap.new_try(catch) local packetno = 1 local authversion = params.authversion or "post41" local username = username or "" if not(HAVE_SSL) then return false, "No OpenSSL" end if authversion ~= "post41" then return false, "Unsupported authentication version: " .. authversion end local clicap = Capabilities.LongPassword clicap = clicap + Capabilities.LongColumnFlag clicap = clicap + Capabilities.SupportsLoadDataLocal clicap = clicap + Capabilities.Speaks41ProtocolNew clicap = clicap + Capabilities.InteractiveClient clicap = clicap + Capabilities.SupportsTransactions clicap = clicap + Capabilities.Support41Auth local extcapabilities = ExtCapabilities.SupportsMultipleStatments extcapabilities = extcapabilities + ExtCapabilities.SupportsMultipleResults local hash = "" if ( password ~= nil and password:len() > 0 ) then hash = createLoginHash( password, salt ) end local packet = string.pack( " 0 then local has_sqlstate response.errorcode, has_sqlstate, pos = string.unpack( "catalog, database, table, -- origt_table, name, orig_name, -- length and type function decodeField( data, pos ) local _ local field = {} field.catalog, field.database, field.table, field.orig_table, field.name, field.orig_name, _, -- should be 0x0C _, -- charset, in my case 0x0800 field.length, field.type, pos = string.unpack( "header, fields and data function decodeQueryResponse( socket ) local catch = function() socket:close() stdnse.debug1("decodeQueryResponse(): failed") end local try = nmap.new_try(catch) local data, header, pos local rs, blocks = {}, {} local block_start, block_end local EOF_MARKER = 254 data = try( socket:receive_bytes(HEADER_SIZE) ) pos, header = decodeHeader( data, pos ) -- -- First, Let's attempt to read the "Result Set Header Packet" -- if data:len() < header.len then data = data .. try( socket:receive_bytes( header.len - #data + HEADER_SIZE ) ) end rs.header = data:sub( 1, HEADER_SIZE + header.len ) -- abort on MySQL error if rs.header:sub(HEADER_SIZE + 1, HEADER_SIZE + 1) == "\xFF" then -- is this a 4.0 or 4.1 error message if rs.header:find("#") then return false, rs.header:sub(HEADER_SIZE+10) else return false, rs.header:sub(HEADER_SIZE+4) end end pos = HEADER_SIZE + header.len + 1 -- Second, Let's attempt to read the "Field Packets" and "Row Data Packets" -- They're separated by an "EOF Packet" for i=1,2 do -- marks the start of our block block_start = pos while true do if data:len() - pos < HEADER_SIZE then data = data .. try( socket:receive_bytes( HEADER_SIZE - ( data:len() - pos ) ) ) end pos, header = decodeHeader( data, pos ) if data:len() - pos < header.len - 1 then data = data .. try( socket:receive_bytes( header.len - ( data:len() - pos ) ) ) end if header.len > 0 then local b = string.unpack("B", data, pos ) -- Is this the EOF packet? if b == EOF_MARKER then -- we don't want the EOF Packet included block_end = pos - HEADER_SIZE - 1 pos = pos + header.len break end end pos = pos + header.len end blocks[i] = data:sub( block_start, block_end ) end rs.fields = blocks[1] rs.data = blocks[2] return true, rs end --- Decodes as field packet and returns a table of field tables -- -- ref: http://forge.mysql.com/wiki/MySQL_Internals_ClientServer_Protocol#Field_Packet -- -- @param data string containing field packets -- @param count number containing the amount of fields to decode -- @return status boolean (true on success, false on failure) -- @return fields table containing field tables as returned by decodeField -- or string containing error message if status is false function decodeFieldPackets( data, count ) local pos, header local field, fields = {}, {} if count < 1 then return false, "Field count was less than one, aborting" end for i=1, count do pos, header = decodeHeader( data, pos ) pos, field = decodeField( data, pos ) table.insert( fields, field ) end return true, fields end -- Decodes the result set header -- -- ref: http://forge.mysql.com/wiki/MySQL_Internals_ClientServer_Protocol#Result_Set_Header_Packet -- -- @param data string containing the result set header packet -- @return number containing the amount of fields function decodeResultSetHeader( data ) if data:len() ~= HEADER_SIZE + 1 then return false, "Result set header was incorrect" end local fields = string.unpack( "B", data, HEADER_SIZE + 1 ) return true, fields end --- Decodes the row data -- -- ref: http://forge.mysql.com/wiki/MySQL_Internals_ClientServer_Protocol#Row_Data_Packet -- -- @param data string containing the row data packet -- @param count number containing the number of fields to decode -- @return status true on success, false on failure -- @return rows table containing row tables function decodeDataPackets( data, count ) local pos = 1 local rows = {} while pos <= data:len() do local row = {} local header pos, header = decodeHeader( data, pos ) for i=1, count do row[i], pos = string.unpack("s1", data, pos) end table.insert( rows, row ) end return true, rows end --- Sends the query to the MySQL server and then attempts to decode the response -- -- @param socket socket already connected to mysql -- @param query string containing the sql query -- @return status true on success, false on failure -- @return rows table containing row tables as decoded by decodeDataPackets function sqlQuery( socket, query ) local catch = function() socket:close() stdnse.debug1("sqlQuery(): failed") end local try = nmap.new_try(catch) local packetno = 0 local querylen = query:len() + 1 local packet, packet_len, pos, header local status, fields, field_count, rows, rs packet = string.pack("sqlQuery -- -- @param rs table as returned from sqlQuery -- @param options table containing additional options, currently: -- - noheaders - does not include column names in result -- @return string containing the formatted resultset table function formatResultset(rs, options) options = options or {} if ( not(rs) or not(rs.cols) or not(rs.rows) ) then return end local restab = tab.new(#rs.cols) local colnames = {} if ( not(options.noheaders) ) then for _, col in ipairs(rs.cols) do table.insert(colnames, col.name) end tab.addrow(restab, table.unpack(colnames)) end for _, row in ipairs(rs.rows) do tab.addrow(restab, table.unpack(row)) end return tab.dump(restab) end return _ENV;