diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..fac0153 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.* +!/.gitignore +package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 4427ac4..168cfc6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ -# domdig -DOM XSS scanner for Single Page Applications +## DOMDig +DOMDig is a DOM XSS scanner that runs inside the Chromium web browser and can scan single page applications (SPA) recursively. +It is based on [htcrawl](https://htcrawl.org), a node library powerful enough to easily crawl a gmail account. + + +## KEY FEATURES +- Runs inside a real browser (Chromium) +- Recursive DOM crawling engine +- Handles XHR, fetch, JSONP and websockets requests +- Supports cookies, proxy, custom headers, http auth and more +- Scriptable login sequences + +## GETTING STARTED +### Installation +``` +git clone https://github.com/fcavallarin/domdig.git +cd domdig && npm i && cd .. +node domdig/domdig.js +``` + +### Example +``` +node domdig.js -c 'foo=bar' -p http:127.0.0.1:8080 https://htcap.org/scanme/domxss.php +``` + +### Login Sequence +A login sequence (or initial sequence) is a json object containing a list of actions to take before the scan starts. +Each element of the list is an array where the first element is the name of the action to take and the remaining elements are "parameters" to those actions. +Actions are: +- write <selector> <text> +- click <selector> +- clickToNavigate <selector> +- sleep <seconds> + +#### Example +``` +[ + ["write", "#username", "demo"], + ["write", "#password", "demo"], + ["clickToNavigate", "#btn-login"] +] +``` \ No newline at end of file diff --git a/domdig.js b/domdig.js new file mode 100755 index 0000000..de633f6 --- /dev/null +++ b/domdig.js @@ -0,0 +1,151 @@ +const htcrawl = require('htcrawl'); +const utils = require('./utils'); +const payloads = require('./payloads'); + +const PAYLOADMAP = {}; +const VULNSJAR = []; +var VERBOSE = true; + +function getNewPayload(payload, element){ + const k = "" + Math.floor(Math.random()*4000000000); + const p = payload.replace("{0}", k); + PAYLOADMAP[k] = {payload:payload, element:element}; + return p; +} + +async function crawlAndFuzz(targetUrl, payload, options){ + var hashSet = false; + + // instantiate htcrawl + const crawler = await htcrawl.launch(targetUrl, options); + + // set a sink on page scope + crawler.page().exposeFunction("___xssSink", function(key) { + utils.addVulnerability(PAYLOADMAP[key], VULNSJAR, VERBOSE); + }); + + // fill all inputs with a payload + crawler.on("fillinput", async function(e, crawler){ + const p = getNewPayload(payload, e.params.element); + try{ + await crawler.page().$eval(e.params.element, (i, p) => i.value = p, p); + }catch(e){} + // return false to prevent element to be automatically filled with a random value + return false; + }); + + + // change page hash before the triggering of the first event + crawler.on("triggerevent", async function(e, crawler){ + if(!hashSet){ + const p = getNewPayload(payload, "hash"); + await crawler.page().evaluate(p => document.location.hash = p, p); + hashSet = true; + } + }); + + try{ + await crawler.load(); + } catch(e){ + console.log(`Error ${e}`); + process.exit(-3); + } + + if(options.initSequence){ + let seqline = 1; + for(let seq of options.initSequence){ + switch(seq[0]){ + case "sleep": + await crawler.page().waitFor(seq[1]); + break; + case "write": + try{ + await crawler.page().type(seq[1], seq[2]); + } catch(e){ + utils.sequenceError("element not found", seqline); + } + break; + case "click": + try{ + await crawler.page().click(seq[1]); + } catch(e){ + utils.sequenceError("element not found", seqline); + } + await crawler.waitForRequestsCompletion(); + break; + case "clickToNavigate": + try{ + await crawler.clickToNavigate(seq[1], seq[2]); + } catch(err){ + utils.sequenceError(err, seqline); + } + break; + default: + utils.sequenceError("action not found", seqline); + } + seqline++; + } + } + + + try{ + await crawler.start(); + } catch(e){ + console.log(`Error ${e}`); + process.exit(-4); + } + try{ + await crawler.reload(); + }catch(e){ + utils.error(e); + } + await crawler.page().waitFor(200); + crawler.browser().close(); +} + +function ps(message){ + if(VERBOSE)utils.printStatus(message); +} + +(async () => { + const argv = require('minimist')(process.argv.slice(2), {boolean:["l", "J", "q"]}); + if(argv.q)VERBOSE = false; + if(VERBOSE)utils.banner(); + if('h' in argv){ + utils.usage(); + process.exit(0); + } + + const targetUrl = argv._[0]; + const options = utils.parseArgs(argv); + + if(!targetUrl){ + utils.usage(); + process.exit(1); + } + ps("starting scan"); + + let cnt = 1; + for(let payload of payloads.all){ + ps("crawling page"); + await crawlAndFuzz(targetUrl, payload, options); + ps(cnt + "/" + payloads.all.length + " payloads checked"); + cnt++; + } + + if(VERBOSE)console.log(""); + ps("scan finished, tot vulnerabilities: " + VULNSJAR.length); + + if(argv.J){ + console.log(utils.prettifyJson(VULNSJAR)); + } else if(VERBOSE){ + for(let v of VULNSJAR){ + utils.printVulnerability(v[0], v[1]); + } + } + + if(argv.o){ + let fn = utils.writeJSON(argv.o, VULNSJAR); + ps("findings saved to " + fn) + } +})(); diff --git a/package.json b/package.json new file mode 100755 index 0000000..1c12fa5 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "domdig", + "version": "1.0.0", + "description": "DOM XSS scanner for Single Page Applications", + "main": "domdig.js", + "dependencies": { + "htcrawl": "^1.0.1", + "minimist": "^1.2.0", + "chalk": "^2.4.2" + }, + "devDependencies": { + "htcrawl": "^1.0.1", + "minimist": "^1.2.0", + "chalk": "^2.4.2" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Filippo Cavallarin", + "license": "GPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/fcavallarin/domdig.git" + }, + "keywords": [ + "DOM XSS", + "XSS", + "Cross Site Scripting", + "scanner", + "vulnerability", + "htcrawl", + "puppeteer", + "SPA", + "headless-chrome" + ], + "bugs": { + "url": "https://github.com/fcavallarin/domdig/issues" + } +} diff --git a/payloads.js b/payloads.js new file mode 100755 index 0000000..eb63ed6 --- /dev/null +++ b/payloads.js @@ -0,0 +1,14 @@ +exports.all = [ + ';window.___xssSink({0});', + '', + 'javascript:window.___xssSink({0})', + "'>", + '">', + "'>", + '1 -->', + ']]>', + ' onerror="window.___xssSink({0})"', + '" onerror="window.___xssSink({0})" a="', + "' onerror='window.___xssSink({0})' a='", + 'data:text/javascript;,window.___xssSink({0})' +]; diff --git a/utils.js b/utils.js new file mode 100755 index 0000000..283eae0 --- /dev/null +++ b/utils.js @@ -0,0 +1,221 @@ +const fs = require('fs'); +const chalk = require('chalk'); + +exports.usage = usage; +exports.banner = banner; +exports.parseArgs = parseArgs; +exports.error = error; +exports.addVulnerability = addVulnerability; +exports.printVulnerability = printVulnerability; +exports.printStatus = printStatus; +exports.sequenceError = sequenceError; +exports.writeJSON = writeJSON; +exports.prettifyJson = prettifyJson; +exports.error = error; + +function addVulnerability(vuln, jar, verbose){ + p = vuln.payload.replace("window.___xssSink({0})", "alert(1)"); + jar.push([p, vuln.element]); + if(verbose){ + printVulnerability(p, vuln.element); + } +} + +function printVulnerability(payload, element){ + const msg = chalk.red('[!]') + ` DOM XSS found: ${element} → ${payload}`; + console.log(msg); +} + + +function printStatus(mess){ + console.log(chalk.green("[*] ") + mess); +} + +function error(message){ + console.error(chalk.red(message)); + process.exit(1); +} + +function banner(){ + console.log(chalk.yellow([ + " ___ ____ __ ______ _", + " / _ \\/ __ \\/ |/ / _ \\(_)__ _", + " / // / /_/ / /|_/ / // / / _ `/", + "/____/\\____/_/ /_/____/_/\\_, /" + ].join("\n"))); + console.log(chalk.green(" ver 1.0.0 ") + chalk.yellow("/___/")); + console.log(chalk.yellow("DOM XSS scanner for Single Page Applications")); + console.log(chalk.blue("https://github.com/fcavallarin/domdig")); + console.log(""); +} + +function usage(){ + console.log([ + "domdig [options] url", + "Options:", + " -c COOKIES|PATH set cookies. It can be a string in the form", + " value=key separated by semicolon or JSON.", + " If PATH is a valid readable file,", + " COOKIES are read from that file", + " -A CREDENTIALS username and password used for HTTP", + " authentication separated by a colon", + " -x TIMEOUT set maximum execution time in second for each payload", + " -U USERAGENT set user agent", + " -R REFERER set referer", + " -p PROXY proxy string protocol:host:port", + " protocol can be 'http' or 'socks5'", + " -l do not run chrome in headless mode", + " -E HEADER set extra http headers (ex -E foo=bar -E bar=foo)", + " -s SEQUENCE|PATH set initial sequence (JSON)", + " If PATH is a valid readable file,", + " SEQUENCE is read from that file", + " -o PATH save findings to a JSON file", + " -J print findings as JSON", + " -q quiet mode", + " -h this help" + ].join("\n")); +} + + +function parseCookiesString(str){ + try{ + return JSON.parse(str); + }catch(e){} + var tks = str.split(/; */); + var ret = []; + for(let t of tks){ + let kv = t.split("="); + ret.push({name: kv[0], value:kv.slice(1).join("=")}); + } + return ret; +} + +function parseArgs(args){ + const options = {}; + for(let arg in args){ + switch(arg){ + case "c": + try{ + options.setCookies = parseCookiesString(fs.readFileSync(args[arg])); + } catch(e){ + options.setCookies = parseCookiesString(args[arg]); + } + break; + case "A": + var arr = args[arg].split(":"); + options.httpAuth = [arr[0], arr.slice(1).join(":")]; + break; + case "x": + options.maxExecTime = parseInt(args[arg]) * 1000; + break; + case "U": + options.userAgent = args[arg]; + break; + case "R": + options.referer = args[arg]; + break; + case "p": + var tmp = args[arg].split(":"); + if(tmp.length > 2){ + options.proxy = tmp[0] + "://" + tmp[1] + ":" + tmp[2]; + } else { + options.proxy = args[arg]; + } + break; + case "l": + options.headlessChrome = !args[arg]; + break; + case "E": + let hdrs = typeof args[arg] == 'string' ? [args[arg]] : args[arg]; + options.extraHeaders = {}; + for(let h of hdrs){ + let t = h.split("="); + options.extraHeaders[t[0]] = t[1]; + } + break; + + case "s": + try{ + options.initSequence = JSON.parse(fs.readFileSync(args[arg])); + } catch(e){ + try{ + options.initSequence = JSON.parse(args[arg]); + }catch(e){ + console.error(chalk.red("JSON error in sequence")); + process.exit(1); + } + } + break; + } + } + return options; +} + + +function sequenceError(message, seqline){ + if(seqline){ + message = "action " + seqline + ": " + message; + } + console.error(chalk.red(message)); + process.exit(2); +} + + +function genFilename(fname){ + if(!fs.existsSync(fname)) return fname; + const f = fname.split("."); + const ext = f.slice(-1); + const name = f.slice(0, f.length-1).join("."); + var nf, cnt = 1; + + do { + nf = name + "-" + (cnt++) + "." + ext; + } while(fs.existsSync(nf)); + + return nf; +} + +function writeJSON(file, object){ + let fn = genFilename(file); + fs.writeFileSync(fn, JSON.stringify(object)); + return fn; +} + +function prettifyJson(obj, layer){ + var i, br + out = "", + pd = " ".repeat(2); + if(!layer)layer = 1; + + switch(typeof obj){ + case "object": + if(!obj){ + return chalk.red(obj); + } + if(obj.constructor == Array){ + br = ['[', ']']; + for(i = 0; i < obj.length; i++){ + out += "\n" + pd.repeat(layer) + prettifyJson(obj[i], layer + 1); + out += i < obj.length-1 ? "," : "" + "\n"; + } + } else { + br = ['{', '}']; + var props = Object.keys(obj); + for(i = 0; i < props.length; i++){ + out += "\n" + pd.repeat(layer) + '"' + chalk.red(props[i]) + '": '; + out += prettifyJson(obj[props[i]], layer + 1); + out += i < props.length-1 ? "," : "" + "\n"; + } + } + if(!out) return br.join(""); + // padding of prv layer to align the closing bracket + return br[0] + out + pd.repeat(layer-1) + br[1]; + case "string": + return chalk.blue(JSON.stringify(obj)); + case "number": + return chalk.green(obj); + case "boolean": + return chalk.yellow(obj); + } + return obj; +}