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;
+}