Nmap Development mailing list archives
[NSE script] web application fingerprinting
From: Sven Klemm <sven () c3d2 de>
Date: Sun, 24 Aug 2008 22:08:06 +0200
Hi, I've updated my web application fingerprinting script to current nmap. The script requires libxml2. You can checkout the code from svn://svn.insecure.org/nmap-exp/sven/nse_sedusa I've also attached patch which includes all required changes. The output looks like this: ./nmap host -p 80 --script webapp Starting Nmap 4.68 ( http://nmap.org ) at 2008-08-24 21:41 CEST Interesting ports on host: PORT STATE SERVICE 80/tcp open http |_ WebApp: WordPress 2.0.11 Nmap done: 1 IP address (1 host up) scanned in 2.52 seconds ./nmap host -p443 --script webapp Starting Nmap 4.68 ( http://nmap.org ) at 2008-08-24 21:42 CEST Interesting ports on host: PORT STATE SERVICE 443/tcp open https |_ WebApp: MediaWiki 1.13alpha (r31491) Nmap done: 1 IP address (1 host up) scanned in 4.07 seconds Cheers, Sven -- Sven Klemm http://cthulhu.c3d2.de/~sven/
Index: scripts/webapp.nse =================================================================== --- scripts/webapp.nse (.../nmap) (revision 0) +++ scripts/webapp.nse (.../nmap-exp/sven/nse_sedusa) (revision 9708) @@ -0,0 +1,64 @@ +--- +-- Try to guess running webapp and their version. +-- +--@output +--@output 80/tcp open http |_ WebApp: Trac 0.11dev-r6306 80/tcp open http |_ WebApp: MediaWiki 1.14alpha 80/tcp open http |_ WebApp: WordPress 2.0.11 + +require "stdnse" +require "shortport" +require "sedusa" + +id = "WebApp" +description = "Try to guess running webapp and their version." +author = "Sven Klemm <sven () c3d2 de>" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"intrusive","safe","discovery"} + + +portrule = shortport.port_or_service({80,443},{'http', 'https', 'http-proxy'}) + +action = function(host, port) + local scheme, hostname, app, exps, index, xpath, u, doc + local target = {} + + if port.service == 'https' or port.version.service_tunnel == 'ssl' then + target.scheme = "https" + if port.number ~= 443 then target.port = port.number end + else + target.scheme = "http" + if port.number ~= 80 then target.port = port.number end + end + + target.host = host.targetname or ( host.name and host.name ~= "" and host.name ) or host.ip + target.path = nmap.registry.args.webapp or "/" + + u = url.build( target ) + doc = sedusa.get_document( u ) + + for app, exps in pairs( sedusa.hints ) do + for index, xpath in pairs( exps ) do + if doc.xml:find( xpath ) then + if sedusa.verify[app] then + local version = sedusa.verify[app]( u ) + if version then + return app .. " " .. version + else + return app + end + else + stdnse.print_debug( "No verify function for %s found.", app ) + return app + end + break + end + end + end + +end + Index: nselib-bin/xml.c =================================================================== --- nselib-bin/xml.c (.../nmap) (revision 0) +++ nselib-bin/xml.c (.../nmap-exp/sven/nse_sedusa) (revision 9708) @@ -0,0 +1,151 @@ + +#include "xml.h" + +#include <libxml/HTMLparser.h> +#include <libxml/xpath.h> + +typedef struct XmlDocumentData { + xmlDocPtr document; +} XmlDocumentData; + +// create a lua XmlDocument +int create_document( lua_State * L, xmlDocPtr doc ) { + if ( doc ) { + // parsing successful + XmlDocumentData * doc_data; + doc_data = (XmlDocumentData *) lua_newuserdata( L, sizeof(XmlDocumentData)); + // set metatable for userdata + luaL_getmetatable( L, "XmlDocument" ); + lua_setmetatable( L, -2 ); + doc_data->document = doc; + } else { + // parsing failed + luaL_error( L , "parsing document failed." ); + } + return 1; +} + +// takes an html document as string and returns a XmlDocument +int xml_parse_html( lua_State * L ) { + const char * doc_string = luaL_checkstring( L, 1 ); + lua_pop(L, 1); + char * url = NULL; + char * encoding = NULL; + + int options = HTML_PARSE_RECOVER | HTML_PARSE_NOERROR | HTML_PARSE_NOWARNING | HTML_PARSE_NONET; + htmlDocPtr doc = htmlReadDoc( doc_string, url, encoding, options ); + + return create_document( L, doc ); +} + +// takes an xml document as string and returns a XmlDocument +int xml_parse_xml( lua_State * L ) { + const char * doc_string = luaL_checkstring( L, 1 ); + lua_pop(L, 1); + char * url = NULL; + char * encoding = NULL; + + int options = XML_PARSE_RECOVER | XML_PARSE_NOERROR | XML_PARSE_NOWARNING | XML_PARSE_NONET; + xmlDocPtr doc = xmlReadDoc( doc_string, url, encoding, options ); + + return create_document( L, doc ); +} + + +static const struct luaL_reg xml_methods[] = { + { "parse_html", xml_parse_html }, + { "parse_xml", xml_parse_xml }, + { NULL, NULL } +}; + +// takes an xpath expression and return the match(es) as string(s) +int xmldoc_find( lua_State * L ) { + XmlDocumentData * doc = (XmlDocumentData *) luaL_checkudata(L, 1, "XmlDocument"); + const char * xpath = luaL_checkstring( L, 2 ); + xmlXPathContextPtr context = xmlXPathNewContext( doc->document ); + + if ( !context ) luaL_error( L, "Error creating context." ); + + xmlXPathObjectPtr result = xmlXPathEvalExpression(xpath, context); + xmlXPathFreeContext(context); + if ( !result ) luaL_error( L, "Error while evaluating XPath expression." ); + + if( xmlXPathNodeSetIsEmpty( result->nodesetval ) ) { + // empty resultset + lua_pushnil( L ); + xmlXPathFreeNodeSetList( result ); + return 1; + } else { + int i; + // we found something .. return first match as string + xmlNodeSetPtr nodeset = result->nodesetval; + char * tmp = xmlXPathCastNodeToString( nodeset->nodeTab[0] ); + lua_pushstring( L, tmp ); + free( tmp ); + xmlXPathFreeNodeSetList( result ); + return 1; + } +} + +// takes an xpath expression and return the match(es) as table containing the string(s) +int xmldoc_find_all( lua_State * L ) { + XmlDocumentData * doc = (XmlDocumentData *) luaL_checkudata(L, 1, "XmlDocument"); + const char * xpath = luaL_checkstring( L, 2 ); + xmlXPathContextPtr context = xmlXPathNewContext( doc->document ); + + if ( !context ) luaL_error( L, "Error creating context." ); + + xmlXPathObjectPtr result = xmlXPathEvalExpression(xpath, context); + xmlXPathFreeContext(context); + if ( !result ) luaL_error( L, "Error while evaluating XPath expression." ); + + lua_newtable( L ); + + if( xmlXPathNodeSetIsEmpty( result->nodesetval ) ) { + // empty resultset + } else { + int i; + // we found something + xmlNodeSetPtr nodeset = result->nodesetval; + for ( i = 0; i < nodeset->nodeNr; i++) { + lua_pushnumber( L, i + 1 ); + char * tmp = xmlXPathCastNodeToString( nodeset->nodeTab[i] ); + lua_pushstring( L, tmp ); + free( tmp ); + lua_rawset( L, -3 ); + } + } + xmlXPathFreeNodeSetList( result ); + return 1; +} + +int xmldoc_free( lua_State * L ) { + XmlDocumentData * doc = (XmlDocumentData *) luaL_checkudata(L, 1, "XmlDocument"); + free( doc->document ); + return 0; +} + +// XmlDocument methods +static const struct luaL_reg xmldoc_methods[] = { + { "find", xmldoc_find }, + { "find_all", xmldoc_find_all}, + { "__gc", xmldoc_free }, + { NULL, NULL } +}; + + +// initializer function, called when library is required +int luaopen_xml( lua_State * L ) { + + // create metatable + luaL_newmetatable( L, "XmlDocument" ); + // metatable.__index = metatable + lua_pushvalue( L, -1 ); + lua_setfield( L, -2, "__index" ); + // register methods + luaL_register( L, NULL, xmldoc_methods ); + + luaL_register( L, "xml", xml_methods ); + return 1; +} + Index: nselib-bin/Makefile.in =================================================================== --- nselib-bin/Makefile.in (.../nmap) (revision 9708) +++ nselib-bin/Makefile.in (.../nmap-exp/sven/nse_sedusa) (revision 9708) @@ -15,15 +15,20 @@ LIBTOOL= ./libtool LTFLAGS = --tag=CC --silent -all: bit.so +all: bit.so xml.so bit.so: bit.c @LIBTOOL_DEPS@ $(LIBTOOL) $(LTFLAGS) --mode=compile $(CC) $(CPPFLAGS) $(CFLAGS) -c bit.c $(LIBTOOL) $(LTFLAGS) --mode=link $(CC) -avoid-version -module -rpath $(nselib_bindir) $(LDFLAGS) -o bit.la bit.lo $(LIBS) mv .libs/bit.so bit.so +xml.so: xml.c @LIBTOOL_DEPS@ + $(LIBTOOL) $(LTFLAGS) --mode=compile $(CC) $(CPPFLAGS) $(CFLAGS) -I/usr/include/libxml2 -c xml.c + $(LIBTOOL) $(LTFLAGS) --mode=link $(CC) -avoid-version -module -rpath $(nselib_bindir) $(LDFLAGS) -o xml.la xml.lo $(LIBS) + mv .libs/xml.so xml.so + clean: - rm -f bit.so *.la *.lo + rm -f bit.so xml.so *.la *.lo rm -rf .libs distclean: clean Index: nselib-bin/xml.h =================================================================== --- nselib-bin/xml.h (.../nmap) (revision 0) +++ nselib-bin/xml.h (.../nmap-exp/sven/nse_sedusa) (revision 9708) @@ -0,0 +1,13 @@ + +#ifndef XMLLIB +#define XMLLIB + +#define XMLLIBNAME "xml" + +#include "lauxlib.h" +#include "lua.h" + +LUALIB_API int luaopen_xml(lua_State *L); + +#endif + Index: nselib/http.lua =================================================================== --- nselib/http.lua (.../nmap) (revision 9708) +++ nselib/http.lua (.../nmap-exp/sven/nse_sedusa) (revision 9708) @@ -73,8 +73,8 @@ -- @param url The url of the host. -- @param options Options passed to http.get. -- @see http.get -get_url = function(url, options ) - local parsed = url.parse( url ) +get_url = function( u, options ) + local parsed = url.parse( u ) local port = {} port.service = parsed.scheme @@ -143,7 +143,7 @@ return result end - local buffer = stdnse.make_buffer( socket, "\r?\n" ) + local buffer = stdnse.make_buffer( socket, "\r\n" ) local line, _ local header, body = {}, {} @@ -184,15 +184,32 @@ end end - -- body loop - while true do - line = buffer() - if not line then break end - table.insert(body,line) + -- handle body + if result.header['transfer-encoding'] == 'chunked' then + -- if the server used chunked encoding we have to 'dechunk' the answer + local counter, chunk_size + counter = 0; chunk_size = 0 + while true do + if counter >= chunk_size then + counter = 0 + chunk_size = tonumber( buffer(), 16 ) + if chunk_size == 0 or not chunk_size then break end + end + line = buffer() + if not line then break end + counter = counter + #line + 2 + table.insert(body,line) + end + else + while true do + line = buffer() + if not line then break end + table.insert(body,line) + end end socket:close() - result.body = table.concat( body, "\n" ) + result.body = table.concat( body, "\r\n" ) return result Index: nselib/sedusa.lua =================================================================== --- nselib/sedusa.lua (.../nmap) (revision 0) +++ nselib/sedusa.lua (.../nmap-exp/sven/nse_sedusa) (revision 9708) @@ -0,0 +1,316 @@ +--- +--@author Sven Klemm <sven () c3d2 de> + +module(... or "sedusa", package.seeall) + +require 'stdnse' +require 'http' +require 'xml' + +hints = {} +verify = {} + +document_cache = {} + +Document = { + new = function( doc ) + doc.xml = nil + if string.len( doc.body ) > 0 then + doc.xml = xml.parse_html( doc.body ) + end + return doc + end +} + +http_get = function( u ) + stdnse.print_debug( 2, "Fetching %s", u ) + local document = http.get_url( u ) + return Document.new( document ) +end + +get_document = function( url ) + local document + + if not document_cache[ url ] then + document = http_get( url ) + document_cache[ url ] = document + else + document = document_cache[ url ] + end + if document.status == 301 or + document.status == 302 + then + -- take care of relative redirects + if not document.header.location:match('https?://') then + local base = url:match('^(.*/)[^/]*$') + document.header.location = base .. document.header.location + end + document = get_document( document.header.location ) + end + + return document +end + + +-- setup default detector for some web applications +for key, app in pairs( {'b2evolution', 'bBlog', 'C3D2-Web', 'DokuWiki', 'gitweb', 'Midgard', 'Nucleus CMS', 'Pentabarf', 'PhpWiki', 'Plone', 'PostNuke', 'TYPO3', 'vBulletin'} ) do + hints[app] = { '//meta[@name="generator" and starts-with(@content,"' .. app .. '")]' } + verify[app] = function( url ) + local document = get_document( url ) + -- look in generator meta tag + local generator = document.xml:find( '//meta[@name="generator"]/@content' ) + if generator and string.match( generator, app .. " (.*)" ) then + return string.match( generator, app .. " (.*)" ) + end + end +end + +hints['Bugzilla'] = { + '//div[@id="banner"]/p[@id="banner-version"]/a[@href="http://www.bugzilla.org/"]/span[text()="Bugzilla"]', + '//div[@id="header"]/table/tr/td[@id="title"]/p[starts-with(text(),"Bugzilla")]', +} + +verify['Bugzilla'] = function( u ) + local doc = get_document( u ) + local version = doc.xml:find( '//div[@id="banner"]/p[@id="banner-version" and a[@href="http://www.bugzilla.org/"]/span[text()="Bugzilla"]]/span[starts-with(text(),"Version ")]/text()' ) + if version then + return version:match('Version (.*)') + end + version = doc.xml:find( '//div[@id="header"]/table/tr/td[@id="information"]/p[@class="header_addl_info"]/text()' ) + if version then + return version:match('version (.*)') + end +end + +hints['Burning Board'] = { + '//span[@class="smallfont" and text()="Powered by "]/b[starts-with(text(),"WoltLab Burning Board")]', + '//span[@class="smallfont"]/a[@href="http://www.woltlab.de"]/b[contains(text(),"Burning Board")]', +} + +verify['Burning Board'] = function( u ) + local doc = get_document( u ) + local version = doc.xml:find( hints['Burning Board'][1] ) + if not version then version = doc.xml:find( hints['Burning Board'][2] ) end + if version then + return version:match('Burning Board (.*)') + end +end + +hints['CommunityServer'] = { + '//meta[@name="GENERATOR" and starts-with(@content, "CommunityServer")]/@content', + '//a[@href="http://communityserver.org/r.ashx?1" and @target="_blank"]/img[starts-with(@alt,"Powered by CommunityServer")]', +} + +verify['CommunityServer'] = function( u ) + local doc = get_document( u ) + local version = doc.xml:find( hints['CommunityServer'][1] ) + if version then + return version:match("^CommunityServer (.*)$") + end +end + +hints['Cisco VPN 3000 Concentrator'] = { + '//title[starts-with(@text,"Cisco Systems, Inc. VPN 3000 Concentrator [")]', + '//form/table/tr/td[@align="right" and @colspan="2" and text()="VPN 3000 Concentrator"]', +} + +hints['Drupal'] = { + '//div[@id="block-user-0" and h2[text()="User login"]]/div[@class="content"]/form[@id="user-login-form"]/div[div[@class="form-item"]/input[@type="text" and @class="form-text required"]]', + '//p[@class="foot2"]/a[@href="http://drupal.org"]/img[contains(@title,"Powered by Drupal")]', +} + +hints['Flyspray'] = { + '//p[@id="footer"]/a[@href="http://flyspray.org/" and @class="offsite" and starts-with(text(),"Powered by Flyspray")]', + '//p[@id="footer"]/a[@href="http://flyspray.rocks.cc/" and @class="offsite" and starts-with(text(),"Powered by Flyspray")]', +} + +verify['Flyspray'] = function( u ) + local doc = get_document( u ) + local foot = doc.xml:find( hints['Flyspray'][1] ) + if foot then return foot:match('Powered by Flyspray (.*)') end +end + +hints['Joomla!'] = { + '//meta[@name="Generator" and starts-with(@content,"Joomla!")]' +} + +hints['GForge'] = { + '//a[@href="http://gforge.org"]/img[@alt="Powered by GForge"]', +} + +hints['MediaWiki'] = { + '//body[contains(@class, "mediawiki")]', + '//div[@id="footer"]/div[@id="f-poweredbyico"]/a[@href="http://www.mediawiki.org/"]/img', + '//head/script[@type="text/javascript" and contains(@text,"var wgVersion")]' +} + +verify['MediaWiki'] = function( u ) + local document = get_document( u ) + -- find out location of index.php + local link = document.xml:find( '//a[contains(@href,"index.php?title=") and starts-with(@href, "/")]/@href' ) + if link then + link = link:match( "^(.*/index.php[?]title=).*") + else + link = document.xml:find( '//script[@type="text/javascript" and contains(text(), "var wgScriptPath = ")]' ) + link = link:gsub( "[\r\n]", "" ) + if link then + link = link:match( 'var wgScriptPath = "([^"]+)"') .. '/index.php?title=' + end + end + + -- Special:Version has the most exact version including svn revision + local version = get_document( url.absolute( u, link .. "Special:Version" ) ) + if version.xml:find('//div/ul/li[a[@href="http://www.mediawiki.org/" and text()="MediaWiki"]]') then + return string.match( version.xml:find('//div/ul/li[a[@href="http://www.mediawiki.org/" and text()="MediaWiki"]]'), "MediaWiki: ([^\r\n]+)" ) + end + if version.xml:find('//table[@id="sv-software"]/tr[td[a[@href="http://www.mediawiki.org/" and text()="MediaWiki"]]]') then + return version.xml:find('//table[@id="sv-software"]/tr[td[a[@href="http://www.mediawiki.org/" and text()="MediaWiki"]]]/td[2]') + end + + -- the version without revision is included on every page as javascript variable on recent versions + local js = document.xml:find('//script[@type="text/javascript" and contains(text(), "var wgVersion = ")]') + if js then + js = js:gsub( "[\r\n]", "" ) + return js:match( 'var wgVersion = "([^"]+)"') + end + + -- get version from atom feed + local atom = get_document( url.absolute( u, link .. "Special:Recentchanges&feed=atom" ) ) + if atom.xml:find( '//generator[starts-with(text(), "MediaWiki")]' ) then + return string.match( atom.xml:find( '//generator/text()' ), "^MediaWiki (.*)$" ) + end +end + +hints['OpenWRT'] = { + '//head/title[text()="OpenWrt Administrative Console"]' +} + +verify['OpenWRT'] = function( u ) + local document = get_document( u ) + local webif = document.xml:find( '//head/meta[@http-equiv="refresh"]/@content' ) + if webif and webif:match('0; URL=') then + document = get_document( url.absolute( u, webif:match( '0; URL=(.*)' )) ) + local version = document.xml:find( '//div[@id="header"]/div[@id="header-title"]/div[@id="short-status"]/ul/li[strong[text()="Version:"]]/text()' ) + if version then + return version:match( ' +(.*)' ) + end + end +end + +hints['PHP-Nuke'] = { + '//meta[@name="GENERATOR" and starts-with(@content,"PHP-Nuke")]' +} + +hints['phpMyAdmin'] = { + '//title[starts-with(text(), "phpMyAdmin")]', + '//link[@rel="stylesheet" and @type="text/css" and contains(@href,"css/phpmyadmin.css.php")]', + '//a[@href="http://www.phpmyadmin.net" and @class="logo" and @target="_blank"]/img[@alt="phpMyAdmin"]' +} + +verify['phpMyAdmin'] = function( url ) + local document = get_document( url ) + local title = document.xml:find( '//title[starts-with(text(), "phpMyAdmin")]/text()' ) + local version = title:match('^phpMyAdmin (.*)$') + if version then + return version + end +end + +hints['phpSysInfo'] = { + '//a[@href="http://phpsysinfo.sourceforge.net" and @target="_blank" and starts-with(text(),"phpSysInfo")]' +} + +verify['phpSysInfo'] = function( url ) + local document = get_document( url ) + local version = document.xml:find( '//a[@href="http://phpsysinfo.sourceforge.net" and @target="_blank" and starts-with(text(),"phpSysInfo")]/text()' ) + version = version:match('^phpSysInfo[-](.*)$') + if version then + return version + end +end + +verify['PhpWiki'] = function( url ) + local document = get_document( url ) + local generator = document.xml:find( '//meta[@name="PHPWIKI_VERSION"]/@content' ) + if generator then return generator end +end + +verify['Plone'] = function( u ) + local document = get_document( u ) + -- look in header + if document.header.server then + local version = document.header.server:match( 'Plone/(%d.%d.%d)' ) + if version then return version end + end +end + +hints['Serendipity'] = { + '//meta[@name="Powered-By" and starts-with(@content, "Serendipity")]', + '//div[@id="serendipity_banner"]', + '//div[@class="serendipity_entry_body"]', + '//div[@class="serendipity_entryFooter"]', +} + +verify['Serendipity'] = function( u ) + local document = get_document( u ) + local generator = document.xml:find( '//meta[@name="Powered-By"]/@content' ) + if generator and string.match( generator, 'Serendipity v[.](.*)' ) then + return string.match( generator, 'Serendipity v[.](.*)' ) + end + local rss_url = document.xml:find( '//link[@rel="alternate" and @type="application/rss+xml"]/@href' ) + if rss_url then + document = get_document( url.absolute( u, rss_url ) ) + generator = document.xml:find( '//generator') + if generator and string.match( generator, 'Serendipity [0-9.]+' ) then + return string.match( generator, 'Serendipity ([0-9.]+)' ) + end + end +end + +hints['SMF'] = { + '//span[@class="smalltext"]/a[@href="http://www.simplemachines.org/" and contains(text(),"Powered by SMF")]', +} + +verify['SMF'] = function(u) + local doc = get_document( u ) + local foot = doc.xml:find( hints['SMF'][1] ) + if foot then + return foot:match('Powered by SMF (.*)') + end +end + +hints['Trac'] = { + '//div[@id="footer"]/a[@id="tracpowered" and @href="http://trac.edgewall.org/"]/img[@alt="Trac Powered"]', +} + +verify['Trac'] = function( u ) + local document = get_document( u ) + local version = document.xml:find( '//div[@id="footer" and a[@id="tracpowered" and @href="http://trac.edgewall.org/"]]/p[@class="left"]/a/strong/text()' ) + if version and version:match( 'Trac (.*)' ) then + return version:match( 'Trac (.*)' ) + end +end + +hints['WordPress'] = { + '//meta[@name="generator" and starts-with(@content,"WordPress")]', + '//head/link[@rel="stylesheet" and @type="text/css" and contains( @href, "/wp-content/")]', + '//head/style[contains( text(), "/wp-content/")]', + '//div[@id="content"]/div[@class="post" and starts-with(@id, "post-") and div[@class="posttitle"] and div[@class="postmeta"] and div[@class="postbody"] and div[@class="postfooter"]]', + '//ul/li/a[@href="http://wordpress.org/" and starts-with(@title,"Powered by WordPress")]', +} + +verify['WordPress'] = function( u ) + local document = get_document( u ) + -- look in generator meta tag + local generator = document.xml:find( '//meta[@name="generator"]/@content' ) + if generator and string.match( generator, "WordPress (.*)" ) then + return string.match( generator, "WordPress (.*)" ) + end + -- look in atom feed + local atom = document.xml:find( '//link[@rel="alternate" and @type="application/atom+xml"]/@href' ) + local feed = get_document( atom ) + if feed.xml:find( '//generator[text()="WordPress"]/@version' ) then + return feed.xml:find( '//generator[text()="WordPress"]/@version' ) + end +end +
_______________________________________________ Sent through the nmap-dev mailing list http://cgi.insecure.org/mailman/listinfo/nmap-dev Archived at http://SecLists.Org
Current thread:
- [NSE script] web application fingerprinting Sven Klemm (Aug 24)