mirror of
https://github.com/bitwarden/browser
synced 2026-01-06 18:43:25 +00:00
[PM-27786] Chrome application bound encryption v3 support (#17205)
* Update cargo.lock on windows * Move ABE key decoding to helper.exe * Safe slice operations (no panics) * Refactor CNG code a bit * Refactor CNG code a bit more * Update README to match the new flow * DRY up v1 and v2 decryption * Remove all the crates and windows features that are not needed * helper.exe split into a bunch of files * Refator mod windows * Minor cleanup --------- Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
3
apps/desktop/desktop_native/Cargo.lock
generated
3
apps/desktop/desktop_native/Cargo.lock
generated
@@ -444,8 +444,10 @@ dependencies = [
|
||||
name = "bitwarden_chromium_import_helper"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
"chromium_importer",
|
||||
"clap",
|
||||
"embed-resource",
|
||||
@@ -606,7 +608,6 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"cbc",
|
||||
"chacha20poly1305",
|
||||
"dirs",
|
||||
"hex",
|
||||
"oo7",
|
||||
|
||||
@@ -20,6 +20,7 @@ publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
aes = "=0.8.4"
|
||||
aes-gcm = "=0.10.3"
|
||||
anyhow = "=1.0.94"
|
||||
arboard = { version = "=3.6.0", default-features = false }
|
||||
ashpd = "=0.11.0"
|
||||
|
||||
@@ -8,23 +8,14 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
aes-gcm = { workspace = true }
|
||||
chacha20poly1305 = { workspace = true }
|
||||
chromium_importer = { path = "../chromium_importer" }
|
||||
clap = { version = "=4.5.40", features = ["derive"] }
|
||||
scopeguard = { workspace = true }
|
||||
sysinfo = { workspace = true }
|
||||
windows = { workspace = true, features = [
|
||||
"Wdk_System_SystemServices",
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Services",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
anyhow = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
mod windows_binary {
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use clap::Parser;
|
||||
use scopeguard::defer;
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
os::windows::{ffi::OsStringExt as _, io::AsRawHandle},
|
||||
path::{Path, PathBuf},
|
||||
ptr,
|
||||
time::Duration,
|
||||
};
|
||||
use sysinfo::System;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::windows::named_pipe::{ClientOptions, NamedPipeClient},
|
||||
time,
|
||||
};
|
||||
use tracing::{debug, error, level_filters::LevelFilter};
|
||||
use tracing_subscriber::{
|
||||
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
|
||||
};
|
||||
use windows::{
|
||||
core::BOOL,
|
||||
Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE,
|
||||
Win32::{
|
||||
Foundation::{
|
||||
CloseHandle, LocalFree, ERROR_PIPE_BUSY, HANDLE, HLOCAL, NTSTATUS, STATUS_SUCCESS,
|
||||
},
|
||||
Security::{
|
||||
self,
|
||||
Cryptography::{CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB},
|
||||
DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE,
|
||||
TOKEN_QUERY,
|
||||
},
|
||||
System::{
|
||||
Pipes::GetNamedPipeServerProcessId,
|
||||
Threading::{
|
||||
OpenProcess, OpenProcessToken, QueryFullProcessImageNameW, PROCESS_NAME_WIN32,
|
||||
PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
},
|
||||
},
|
||||
UI::Shell::IsUserAnAdmin,
|
||||
},
|
||||
};
|
||||
|
||||
use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bitwarden_chromium_import_helper")]
|
||||
#[command(about = "Admin tool for ABE service management")]
|
||||
struct Args {
|
||||
/// Base64 encoded encrypted data to process
|
||||
#[arg(long, help = "Base64 encoded encrypted data string")]
|
||||
encrypted: String,
|
||||
}
|
||||
|
||||
// Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost.
|
||||
// This is intended for development time only. All the logging is wrapped in `dbg_log!`` macro that compiles to
|
||||
// no-op when logging is disabled. This is needed to avoid any sensitive data being logged in production. Normally
|
||||
// all the logging code is present in the release build and could be enabled via RUST_LOG environment variable.
|
||||
// We don't want that!
|
||||
const ENABLE_DEVELOPER_LOGGING: bool = false;
|
||||
const LOG_FILENAME: &str = "c:\\path\\to\\log.txt"; // This is an example filename, replace it with you own
|
||||
|
||||
// This should be enabled for production
|
||||
const ENABLE_SERVER_SIGNATURE_VALIDATION: bool = true;
|
||||
|
||||
// List of SYSTEM process names to try to impersonate
|
||||
const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"];
|
||||
|
||||
// Macro wrapper around debug! that compiles to no-op when ENABLE_DEVELOPER_LOGGING is false
|
||||
macro_rules! dbg_log {
|
||||
($($arg:tt)*) => {
|
||||
if ENABLE_DEVELOPER_LOGGING {
|
||||
debug!($($arg)*);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fn open_pipe_client(pipe_name: &'static str) -> Result<NamedPipeClient> {
|
||||
let max_attempts = 5;
|
||||
for _ in 0..max_attempts {
|
||||
match ClientOptions::new().open(pipe_name) {
|
||||
Ok(client) => {
|
||||
dbg_log!("Successfully connected to the pipe!");
|
||||
return Ok(client);
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => {
|
||||
dbg_log!("Pipe is busy, retrying in 50ms...");
|
||||
}
|
||||
Err(e) => {
|
||||
dbg_log!("Failed to connect to pipe: {}", &e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Failed to connect to pipe after {} attempts",
|
||||
max_attempts
|
||||
))
|
||||
}
|
||||
|
||||
async fn send_message_with_client(
|
||||
client: &mut NamedPipeClient,
|
||||
message: &str,
|
||||
) -> Result<String> {
|
||||
client.write_all(message.as_bytes()).await?;
|
||||
|
||||
// Try to receive a response for this message
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
match client.read(&mut buffer).await {
|
||||
Ok(0) => Err(anyhow!(
|
||||
"Server closed the connection (0 bytes read) on message"
|
||||
)),
|
||||
Ok(bytes_received) => {
|
||||
let response = String::from_utf8_lossy(&buffer[..bytes_received]);
|
||||
Ok(response.to_string())
|
||||
}
|
||||
Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result<u32> {
|
||||
let handle = HANDLE(client.as_raw_handle() as _);
|
||||
let mut pid: u32 = 0;
|
||||
unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?;
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
fn resolve_process_executable_path(pid: u32) -> Result<PathBuf> {
|
||||
dbg_log!("Resolving process executable path for PID {}", pid);
|
||||
|
||||
// Open the process handle
|
||||
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
|
||||
dbg_log!("Opened process handle for PID {}", pid);
|
||||
|
||||
// Close when no longer needed
|
||||
defer! {
|
||||
dbg_log!("Closing process handle for PID {}", pid);
|
||||
unsafe {
|
||||
_ = CloseHandle(hprocess);
|
||||
}
|
||||
};
|
||||
|
||||
let mut exe_name = vec![0u16; 32 * 1024];
|
||||
let mut exe_name_length = exe_name.len() as u32;
|
||||
unsafe {
|
||||
QueryFullProcessImageNameW(
|
||||
hprocess,
|
||||
PROCESS_NAME_WIN32,
|
||||
windows::core::PWSTR(exe_name.as_mut_ptr()),
|
||||
&mut exe_name_length,
|
||||
)
|
||||
}?;
|
||||
dbg_log!(
|
||||
"QueryFullProcessImageNameW returned {} bytes",
|
||||
exe_name_length
|
||||
);
|
||||
|
||||
exe_name.truncate(exe_name_length as usize);
|
||||
Ok(PathBuf::from(OsString::from_wide(&exe_name)))
|
||||
}
|
||||
|
||||
async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) {
|
||||
_ = send_to_user(client, &format!("!{}", error_message)).await
|
||||
}
|
||||
|
||||
async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> {
|
||||
let _ = send_message_with_client(client, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_admin() -> bool {
|
||||
unsafe { IsUserAnAdmin().as_bool() }
|
||||
}
|
||||
|
||||
fn decrypt_data_base64(data_base64: &str, expect_appb: bool) -> Result<String> {
|
||||
dbg_log!("Decrypting data base64: {}", data_base64);
|
||||
|
||||
let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| {
|
||||
dbg_log!("Failed to decode base64: {} APPB: {}", e, expect_appb);
|
||||
e
|
||||
})?;
|
||||
|
||||
let decrypted = decrypt_data(&data, expect_appb)?;
|
||||
let decrypted_base64 = general_purpose::STANDARD.encode(decrypted);
|
||||
|
||||
Ok(decrypted_base64)
|
||||
}
|
||||
|
||||
fn decrypt_data(data: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
|
||||
if expect_appb && !data.starts_with(b"APPB") {
|
||||
dbg_log!("Decoded data does not start with 'APPB'");
|
||||
return Err(anyhow!("Decoded data does not start with 'APPB'"));
|
||||
}
|
||||
|
||||
let data = if expect_appb { &data[4..] } else { data };
|
||||
|
||||
let in_blob = CRYPT_INTEGER_BLOB {
|
||||
cbData: data.len() as u32,
|
||||
pbData: data.as_ptr() as *mut u8,
|
||||
};
|
||||
|
||||
let mut out_blob = CRYPT_INTEGER_BLOB {
|
||||
cbData: 0,
|
||||
pbData: ptr::null_mut(),
|
||||
};
|
||||
|
||||
let result = unsafe {
|
||||
CryptUnprotectData(
|
||||
&in_blob,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CRYPTPROTECT_UI_FORBIDDEN,
|
||||
&mut out_blob,
|
||||
)
|
||||
};
|
||||
|
||||
if result.is_ok() && !out_blob.pbData.is_null() && out_blob.cbData > 0 {
|
||||
let decrypted = unsafe {
|
||||
std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize).to_vec()
|
||||
};
|
||||
|
||||
// Free the memory allocated by CryptUnprotectData
|
||||
unsafe { LocalFree(Some(HLOCAL(out_blob.pbData as *mut _))) };
|
||||
|
||||
Ok(decrypted)
|
||||
} else {
|
||||
dbg_log!("CryptUnprotectData failed");
|
||||
Err(anyhow!("CryptUnprotectData failed"))
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Impersonate a SYSTEM process
|
||||
//
|
||||
|
||||
fn start_impersonating() -> Result<HANDLE> {
|
||||
// Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes
|
||||
enable_debug_privilege()?;
|
||||
|
||||
// Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several.
|
||||
let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?;
|
||||
|
||||
// Impersonate the SYSTEM process
|
||||
unsafe {
|
||||
ImpersonateLoggedOnUser(token)?;
|
||||
};
|
||||
dbg_log!("Impersonating system process '{}' (PID: {})", name, pid);
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn stop_impersonating(token: HANDLE) -> Result<()> {
|
||||
unsafe {
|
||||
RevertToSelf()?;
|
||||
CloseHandle(token)?;
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_system_process_with_token(
|
||||
pids: Vec<(u32, &'static str)>,
|
||||
) -> Result<(HANDLE, u32, &'static str)> {
|
||||
for (pid, name) in pids {
|
||||
match get_system_token_from_pid(pid) {
|
||||
Err(_) => {
|
||||
dbg_log!(
|
||||
"Failed to open process handle '{}' (PID: {}), skipping",
|
||||
name,
|
||||
pid
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(system_handle) => {
|
||||
return Ok((system_handle, pid, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("Failed to get system token from any process"))
|
||||
}
|
||||
|
||||
fn get_system_token_from_pid(pid: u32) -> Result<HANDLE> {
|
||||
let handle = get_process_handle(pid)?;
|
||||
let token = get_system_token(handle)?;
|
||||
unsafe {
|
||||
CloseHandle(handle)?;
|
||||
};
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn get_system_token(handle: HANDLE) -> Result<HANDLE> {
|
||||
let token_handle = unsafe {
|
||||
let mut token_handle = HANDLE::default();
|
||||
OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?;
|
||||
token_handle
|
||||
};
|
||||
|
||||
let duplicate_token = unsafe {
|
||||
let mut duplicate_token = HANDLE::default();
|
||||
DuplicateToken(
|
||||
token_handle,
|
||||
Security::SECURITY_IMPERSONATION_LEVEL(2),
|
||||
&mut duplicate_token,
|
||||
)?;
|
||||
CloseHandle(token_handle)?;
|
||||
duplicate_token
|
||||
};
|
||||
|
||||
Ok(duplicate_token)
|
||||
}
|
||||
|
||||
fn get_system_pid_list() -> Vec<(u32, &'static str)> {
|
||||
let sys = System::new_all();
|
||||
SYSTEM_PROCESS_NAMES
|
||||
.iter()
|
||||
.flat_map(|&name| {
|
||||
sys.processes_by_exact_name(name.as_ref())
|
||||
.map(move |process| (process.pid().as_u32(), name))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_process_handle(pid: u32) -> Result<HANDLE> {
|
||||
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
|
||||
Ok(hprocess)
|
||||
}
|
||||
|
||||
#[link(name = "ntdll")]
|
||||
unsafe extern "system" {
|
||||
unsafe fn RtlAdjustPrivilege(
|
||||
privilege: i32,
|
||||
enable: BOOL,
|
||||
current_thread: BOOL,
|
||||
previous_value: *mut BOOL,
|
||||
) -> NTSTATUS;
|
||||
}
|
||||
|
||||
fn enable_debug_privilege() -> Result<()> {
|
||||
let mut previous_value = BOOL(0);
|
||||
let status = unsafe {
|
||||
dbg_log!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege");
|
||||
RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value)
|
||||
};
|
||||
|
||||
match status {
|
||||
STATUS_SUCCESS => {
|
||||
dbg_log!(
|
||||
"SE_DEBUG_PRIVILEGE set to 1, was {} before",
|
||||
previous_value.as_bool()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
dbg_log!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0);
|
||||
Err(anyhow!("Failed to adjust privilege"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Pipe
|
||||
//
|
||||
|
||||
async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result<NamedPipeClient> {
|
||||
let client = open_pipe_client(pipe_name).await?;
|
||||
|
||||
if ENABLE_SERVER_SIGNATURE_VALIDATION {
|
||||
let server_pid = get_named_pipe_server_pid(&client)?;
|
||||
dbg_log!("Connected to pipe server PID {}", server_pid);
|
||||
|
||||
// Validate the server end process signature
|
||||
let exe_path = resolve_process_executable_path(server_pid)?;
|
||||
|
||||
dbg_log!("Pipe server executable path: {}", exe_path.display());
|
||||
|
||||
if !verify_signature(&exe_path)? {
|
||||
return Err(anyhow!("Pipe server signature is not valid"));
|
||||
}
|
||||
|
||||
dbg_log!("Pipe server signature verified for PID {}", server_pid);
|
||||
}
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn run() -> Result<String> {
|
||||
dbg_log!("Starting bitwarden_chromium_import_helper.exe");
|
||||
|
||||
let args = Args::try_parse()?;
|
||||
|
||||
if !is_admin() {
|
||||
return Err(anyhow!("Expected to run with admin privileges"));
|
||||
}
|
||||
|
||||
dbg_log!("Running as ADMINISTRATOR");
|
||||
|
||||
// Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine
|
||||
let system_decrypted_base64 = {
|
||||
let system_token = start_impersonating()?;
|
||||
defer! {
|
||||
dbg_log!("Stopping impersonation");
|
||||
_ = stop_impersonating(system_token);
|
||||
}
|
||||
let system_decrypted_base64 = decrypt_data_base64(&args.encrypted, true)?;
|
||||
dbg_log!("Decrypted data with system");
|
||||
system_decrypted_base64
|
||||
};
|
||||
|
||||
// This is just to check that we're decrypting Chrome keys and not something else sent to us by a malicious actor.
|
||||
// Now that we're back from SYSTEM, we need to decrypt one more time just to verify.
|
||||
// Chrome keys are double encrypted: once at SYSTEM level and once at USER level.
|
||||
// When the decryption fails, it means that we're decrypting something unexpected.
|
||||
// We don't send this result back since the library will decrypt again at USER level.
|
||||
|
||||
_ = decrypt_data_base64(&system_decrypted_base64, false).map_err(|e| {
|
||||
dbg_log!("User level decryption check failed: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
dbg_log!("User level decryption check passed");
|
||||
|
||||
Ok(system_decrypted_base64)
|
||||
}
|
||||
|
||||
fn init_logging(log_path: &Path, file_level: LevelFilter) {
|
||||
// We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW.
|
||||
match std::fs::File::create(log_path) {
|
||||
Ok(file) => {
|
||||
let file_filter = EnvFilter::builder()
|
||||
.with_default_directive(file_level.into())
|
||||
.from_env_lossy();
|
||||
|
||||
let file_layer = fmt::layer()
|
||||
.with_writer(file)
|
||||
.with_ansi(false)
|
||||
.with_filter(file_filter);
|
||||
|
||||
tracing_subscriber::registry().with(file_layer).init();
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, ?log_path, "Could not create log file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn main() {
|
||||
if ENABLE_DEVELOPER_LOGGING {
|
||||
init_logging(LOG_FILENAME.as_ref(), LevelFilter::DEBUG);
|
||||
}
|
||||
|
||||
let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to open pipe {} to send result/error: {}",
|
||||
ADMIN_TO_USER_PIPE_NAME, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match run() {
|
||||
Ok(system_decrypted_base64) => {
|
||||
dbg_log!("Sending response back to user");
|
||||
let _ = send_to_user(&mut client, &system_decrypted_base64).await;
|
||||
}
|
||||
Err(e) => {
|
||||
dbg_log!("Error: {}", e);
|
||||
send_error_to_user(&mut client, &format!("{}", e)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) use windows_binary::*;
|
||||
@@ -0,0 +1,11 @@
|
||||
// Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost.
|
||||
// This is intended for development time only. All the logging is wrapped in `dbg_log!`` macro that compiles to
|
||||
// no-op when logging is disabled. This is needed to avoid any sensitive data being logged in production.
|
||||
pub(crate) const ENABLE_DEVELOPER_LOGGING: bool = false;
|
||||
pub(crate) const LOG_FILENAME: &str = "c:\\path\\to\\log.txt"; // This is an example filename, replace it with you own
|
||||
|
||||
// This should be enabled for production
|
||||
pub(crate) const ENABLE_SERVER_SIGNATURE_VALIDATION: bool = true;
|
||||
|
||||
// List of SYSTEM process names to try to impersonate
|
||||
pub(crate) const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"];
|
||||
@@ -0,0 +1,313 @@
|
||||
use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit};
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use chacha20poly1305::ChaCha20Poly1305;
|
||||
use scopeguard::defer;
|
||||
use tracing::debug;
|
||||
use windows::{
|
||||
core::w,
|
||||
Win32::{
|
||||
Foundation::{LocalFree, HLOCAL},
|
||||
Security::Cryptography::{
|
||||
self, CryptUnprotectData, NCryptOpenKey, NCryptOpenStorageProvider, CERT_KEY_SPEC,
|
||||
CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, NCRYPT_FLAGS, NCRYPT_KEY_HANDLE,
|
||||
NCRYPT_PROV_HANDLE, NCRYPT_SILENT_FLAG,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use super::impersonate::{start_impersonating, stop_impersonating};
|
||||
use crate::dbg_log;
|
||||
|
||||
//
|
||||
// Base64
|
||||
//
|
||||
|
||||
pub(crate) fn decode_base64(data_base64: &str) -> Result<Vec<u8>> {
|
||||
dbg_log!("Decoding base64 data: {}", data_base64);
|
||||
|
||||
let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| {
|
||||
dbg_log!("Failed to decode base64: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub(crate) fn encode_base64(data: &[u8]) -> String {
|
||||
general_purpose::STANDARD.encode(data)
|
||||
}
|
||||
|
||||
//
|
||||
// DPAPI decryption
|
||||
//
|
||||
|
||||
pub(crate) fn decrypt_with_dpapi_as_system(encrypted: &[u8]) -> Result<Vec<u8>> {
|
||||
// Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine
|
||||
let system_token = start_impersonating()?;
|
||||
defer! {
|
||||
dbg_log!("Stopping impersonation");
|
||||
_ = stop_impersonating(system_token);
|
||||
}
|
||||
|
||||
decrypt_with_dpapi_as_user(encrypted, true)
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt_with_dpapi_as_user(encrypted: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
|
||||
let system_decrypted = decrypt_with_dpapi(encrypted, expect_appb)?;
|
||||
dbg_log!(
|
||||
"Decrypted data with SYSTEM {} bytes",
|
||||
system_decrypted.len()
|
||||
);
|
||||
|
||||
Ok(system_decrypted)
|
||||
}
|
||||
|
||||
fn decrypt_with_dpapi(data: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
|
||||
if expect_appb && (data.len() < 5 || !data.starts_with(b"APPB")) {
|
||||
const ERR_MSG: &str = "Ciphertext is too short or does not start with 'APPB'";
|
||||
dbg_log!("{}", ERR_MSG);
|
||||
return Err(anyhow!(ERR_MSG));
|
||||
}
|
||||
|
||||
let data = if expect_appb { &data[4..] } else { data };
|
||||
|
||||
let in_blob = CRYPT_INTEGER_BLOB {
|
||||
cbData: data.len() as u32,
|
||||
pbData: data.as_ptr() as *mut u8,
|
||||
};
|
||||
|
||||
let mut out_blob = CRYPT_INTEGER_BLOB::default();
|
||||
|
||||
let result = unsafe {
|
||||
CryptUnprotectData(
|
||||
&in_blob,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CRYPTPROTECT_UI_FORBIDDEN,
|
||||
&mut out_blob,
|
||||
)
|
||||
};
|
||||
|
||||
if result.is_ok() && !out_blob.pbData.is_null() && out_blob.cbData > 0 {
|
||||
let decrypted = unsafe {
|
||||
std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize).to_vec()
|
||||
};
|
||||
|
||||
// Free the memory allocated by CryptUnprotectData
|
||||
unsafe { LocalFree(Some(HLOCAL(out_blob.pbData as *mut _))) };
|
||||
|
||||
Ok(decrypted)
|
||||
} else {
|
||||
dbg_log!("CryptUnprotectData failed");
|
||||
Err(anyhow!("CryptUnprotectData failed"))
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Chromium key decoding
|
||||
//
|
||||
|
||||
pub(crate) fn decode_abe_key_blob(blob_data: &[u8]) -> Result<Vec<u8>> {
|
||||
// Parse and skip the header
|
||||
let header_len = u32::from_le_bytes(get_safe(blob_data, 0, 4)?.try_into()?) as usize;
|
||||
debug!("ABE key blob header length: {}", header_len);
|
||||
|
||||
// Parse content length
|
||||
let content_len_offset = 4 + header_len;
|
||||
let content_len =
|
||||
u32::from_le_bytes(get_safe(blob_data, content_len_offset, 4)?.try_into()?) as usize;
|
||||
debug!("ABE key blob content length: {}", content_len);
|
||||
|
||||
if content_len < 32 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: content length is less than 32"
|
||||
));
|
||||
}
|
||||
|
||||
let content_offset = content_len_offset + 4;
|
||||
let content = get_safe(blob_data, content_offset, content_len)?;
|
||||
|
||||
// When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge
|
||||
if content_len == 32 {
|
||||
return Ok(content.to_vec());
|
||||
}
|
||||
|
||||
let version = content[0];
|
||||
debug!("ABE key blob version: {}", version);
|
||||
|
||||
let key_blob = &content[1..];
|
||||
match version {
|
||||
// Google Chrome v1 key encrypted with a hardcoded AES key
|
||||
1_u8 => decrypt_abe_key_blob_chrome_aes(key_blob),
|
||||
// Google Chrome v2 key encrypted with a hardcoded ChaCha20 key
|
||||
2_u8 => decrypt_abe_key_blob_chrome_chacha20(key_blob),
|
||||
// Google Chrome v3 key encrypted with CNG APIs
|
||||
3_u8 => decrypt_abe_key_blob_chrome_cng(key_blob),
|
||||
v => Err(anyhow!("Unsupported ABE key blob version: {}", v)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_safe(data: &[u8], start: usize, len: usize) -> Result<&[u8]> {
|
||||
let end = start + len;
|
||||
data.get(start..end).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Corrupted ABE key blob: expected bytes {}..{}, got {}",
|
||||
start,
|
||||
end,
|
||||
data.len()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result<Vec<u8>> {
|
||||
const GOOGLE_AES_KEY: &[u8] = &[
|
||||
0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93, 0x66,
|
||||
0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D, 0xA0, 0x28,
|
||||
0x47, 0x87,
|
||||
];
|
||||
let aes_key = Key::<Aes256Gcm>::from_slice(GOOGLE_AES_KEY);
|
||||
let cipher = Aes256Gcm::new(aes_key);
|
||||
|
||||
decrypt_abe_key_blob_with_aead(blob, &cipher, "v1 (AES flavor)")
|
||||
}
|
||||
|
||||
fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result<Vec<u8>> {
|
||||
const GOOGLE_CHACHA20_KEY: &[u8] = &[
|
||||
0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25, 0x80,
|
||||
0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D, 0x08, 0x72,
|
||||
0x96, 0x60,
|
||||
];
|
||||
|
||||
let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY);
|
||||
let cipher = ChaCha20Poly1305::new(chacha20_key);
|
||||
|
||||
decrypt_abe_key_blob_with_aead(blob, &cipher, "v2 (ChaCha20 flavor)")
|
||||
}
|
||||
|
||||
fn decrypt_abe_key_blob_with_aead<C>(blob: &[u8], cipher: &C, version: &str) -> Result<Vec<u8>>
|
||||
where
|
||||
C: Aead,
|
||||
{
|
||||
if blob.len() < 60 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
|
||||
blob.len()
|
||||
));
|
||||
}
|
||||
|
||||
let iv = &blob[0..12];
|
||||
let ciphertext = &blob[12..12 + 48];
|
||||
|
||||
debug!("Google ABE {} detected: {:?} {:?}", version, iv, ciphertext);
|
||||
|
||||
let decrypted = cipher
|
||||
.decrypt(iv.into(), ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decrypt v20 key with {}: {}", version, e))?;
|
||||
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result<Vec<u8>> {
|
||||
if blob.len() < 92 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: expected at least 92 bytes, got {} bytes",
|
||||
blob.len()
|
||||
));
|
||||
}
|
||||
|
||||
let encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?;
|
||||
let iv: [u8; 12] = blob[32..32 + 12].try_into()?;
|
||||
let ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?;
|
||||
|
||||
debug!(
|
||||
"Google ABE v3 (CNG flavor) detected: {:?} {:?} {:?}",
|
||||
encrypted_aes_key, iv, ciphertext
|
||||
);
|
||||
|
||||
// First, decrypt the AES key with CNG API
|
||||
let decrypted_aes_key: Vec<u8> = {
|
||||
let system_token = start_impersonating()?;
|
||||
defer! {
|
||||
dbg_log!("Stopping impersonation");
|
||||
_ = stop_impersonating(system_token);
|
||||
}
|
||||
decrypt_with_cng(&encrypted_aes_key)?
|
||||
};
|
||||
|
||||
const GOOGLE_XOR_KEY: [u8; 32] = [
|
||||
0xCC, 0xF8, 0xA1, 0xCE, 0xC5, 0x66, 0x05, 0xB8, 0x51, 0x75, 0x52, 0xBA, 0x1A, 0x2D, 0x06,
|
||||
0x1C, 0x03, 0xA2, 0x9E, 0x90, 0x27, 0x4F, 0xB2, 0xFC, 0xF5, 0x9B, 0xA4, 0xB7, 0x5C, 0x39,
|
||||
0x23, 0x90,
|
||||
];
|
||||
|
||||
// XOR the decrypted AES key with the hardcoded key
|
||||
let aes_key: Vec<u8> = decrypted_aes_key
|
||||
.into_iter()
|
||||
.zip(GOOGLE_XOR_KEY)
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
|
||||
// Decrypt the actual ABE key with the decrypted AES key
|
||||
let cipher = Aes256Gcm::new(aes_key.as_slice().into());
|
||||
let key = cipher
|
||||
.decrypt((&iv).into(), ciphertext.as_ref())
|
||||
.map_err(|e| anyhow!("Failed to decrypt v20 key with AES-GCM: {}", e))?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn decrypt_with_cng(ciphertext: &[u8]) -> Result<Vec<u8>> {
|
||||
// 1. Open the cryptographic provider
|
||||
let mut provider = NCRYPT_PROV_HANDLE::default();
|
||||
unsafe {
|
||||
NCryptOpenStorageProvider(
|
||||
&mut provider,
|
||||
w!("Microsoft Software Key Storage Provider"),
|
||||
0,
|
||||
)?;
|
||||
};
|
||||
|
||||
// Don't forget to free the provider
|
||||
defer!(unsafe {
|
||||
_ = Cryptography::NCryptFreeObject(provider.into());
|
||||
});
|
||||
|
||||
// 2. Open the key
|
||||
let mut key = NCRYPT_KEY_HANDLE::default();
|
||||
unsafe {
|
||||
NCryptOpenKey(
|
||||
provider,
|
||||
&mut key,
|
||||
w!("Google Chromekey1"),
|
||||
CERT_KEY_SPEC::default(),
|
||||
NCRYPT_FLAGS::default(),
|
||||
)?;
|
||||
};
|
||||
|
||||
// Don't forget to free the key
|
||||
defer!(unsafe {
|
||||
_ = Cryptography::NCryptFreeObject(key.into());
|
||||
});
|
||||
|
||||
// 3. Decrypt the data (assume the plaintext is not larger than the ciphertext)
|
||||
let mut plaintext = vec![0; ciphertext.len()];
|
||||
let mut plaintext_len = 0;
|
||||
unsafe {
|
||||
Cryptography::NCryptDecrypt(
|
||||
key,
|
||||
ciphertext.into(),
|
||||
None,
|
||||
Some(&mut plaintext),
|
||||
&mut plaintext_len,
|
||||
NCRYPT_SILENT_FLAG,
|
||||
)?;
|
||||
};
|
||||
|
||||
// In case the plaintext is smaller than the ciphertext
|
||||
plaintext.truncate(plaintext_len as usize);
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use sysinfo::System;
|
||||
use windows::{
|
||||
core::BOOL,
|
||||
Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE,
|
||||
Win32::{
|
||||
Foundation::{CloseHandle, HANDLE, NTSTATUS, STATUS_SUCCESS},
|
||||
Security::{
|
||||
self, DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE,
|
||||
TOKEN_QUERY,
|
||||
},
|
||||
System::Threading::{OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION},
|
||||
},
|
||||
};
|
||||
|
||||
use super::config::SYSTEM_PROCESS_NAMES;
|
||||
use crate::dbg_log;
|
||||
|
||||
#[link(name = "ntdll")]
|
||||
unsafe extern "system" {
|
||||
unsafe fn RtlAdjustPrivilege(
|
||||
privilege: i32,
|
||||
enable: BOOL,
|
||||
current_thread: BOOL,
|
||||
previous_value: *mut BOOL,
|
||||
) -> NTSTATUS;
|
||||
}
|
||||
|
||||
pub(crate) fn start_impersonating() -> Result<HANDLE> {
|
||||
// Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes
|
||||
enable_debug_privilege()?;
|
||||
|
||||
// Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several.
|
||||
let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?;
|
||||
|
||||
// Impersonate the SYSTEM process
|
||||
unsafe {
|
||||
ImpersonateLoggedOnUser(token)?;
|
||||
};
|
||||
dbg_log!("Impersonating system process '{}' (PID: {})", name, pid);
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub(crate) fn stop_impersonating(token: HANDLE) -> Result<()> {
|
||||
unsafe {
|
||||
RevertToSelf()?;
|
||||
CloseHandle(token)?;
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_system_process_with_token(
|
||||
pids: Vec<(u32, &'static str)>,
|
||||
) -> Result<(HANDLE, u32, &'static str)> {
|
||||
for (pid, name) in pids {
|
||||
match get_system_token_from_pid(pid) {
|
||||
Err(_) => {
|
||||
dbg_log!(
|
||||
"Failed to open process handle '{}' (PID: {}), skipping",
|
||||
name,
|
||||
pid
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(system_handle) => {
|
||||
return Ok((system_handle, pid, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("Failed to get system token from any process"))
|
||||
}
|
||||
|
||||
fn get_system_token_from_pid(pid: u32) -> Result<HANDLE> {
|
||||
let handle = get_process_handle(pid)?;
|
||||
let token = get_system_token(handle)?;
|
||||
unsafe {
|
||||
CloseHandle(handle)?;
|
||||
};
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn get_system_token(handle: HANDLE) -> Result<HANDLE> {
|
||||
let token_handle = unsafe {
|
||||
let mut token_handle = HANDLE::default();
|
||||
OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?;
|
||||
token_handle
|
||||
};
|
||||
|
||||
let duplicate_token = unsafe {
|
||||
let mut duplicate_token = HANDLE::default();
|
||||
DuplicateToken(
|
||||
token_handle,
|
||||
Security::SECURITY_IMPERSONATION_LEVEL(2),
|
||||
&mut duplicate_token,
|
||||
)?;
|
||||
CloseHandle(token_handle)?;
|
||||
duplicate_token
|
||||
};
|
||||
|
||||
Ok(duplicate_token)
|
||||
}
|
||||
|
||||
fn get_system_pid_list() -> Vec<(u32, &'static str)> {
|
||||
let sys = System::new_all();
|
||||
SYSTEM_PROCESS_NAMES
|
||||
.iter()
|
||||
.flat_map(|&name| {
|
||||
sys.processes_by_exact_name(name.as_ref())
|
||||
.map(move |process| (process.pid().as_u32(), name))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_process_handle(pid: u32) -> Result<HANDLE> {
|
||||
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
|
||||
Ok(hprocess)
|
||||
}
|
||||
|
||||
fn enable_debug_privilege() -> Result<()> {
|
||||
let mut previous_value = BOOL(0);
|
||||
let status = unsafe {
|
||||
dbg_log!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege");
|
||||
RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value)
|
||||
};
|
||||
|
||||
match status {
|
||||
STATUS_SUCCESS => {
|
||||
dbg_log!(
|
||||
"SE_DEBUG_PRIVILEGE set to 1, was {} before",
|
||||
previous_value.as_bool()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
dbg_log!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0);
|
||||
Err(anyhow!("Failed to adjust privilege"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use tracing::{error, level_filters::LevelFilter};
|
||||
use tracing_subscriber::{
|
||||
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
|
||||
};
|
||||
|
||||
use super::config::{ENABLE_DEVELOPER_LOGGING, LOG_FILENAME};
|
||||
|
||||
// Macro wrapper around debug! that compiles to no-op when ENABLE_DEVELOPER_LOGGING is false
|
||||
#[macro_export]
|
||||
macro_rules! dbg_log {
|
||||
($($arg:tt)*) => {
|
||||
if $crate::windows::config::ENABLE_DEVELOPER_LOGGING {
|
||||
tracing::debug!($($arg)*);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn init_logging() {
|
||||
if ENABLE_DEVELOPER_LOGGING {
|
||||
// We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW.
|
||||
match std::fs::File::create(LOG_FILENAME) {
|
||||
Ok(file) => {
|
||||
let file_filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::DEBUG.into())
|
||||
.from_env_lossy();
|
||||
|
||||
let file_layer = fmt::layer()
|
||||
.with_writer(file)
|
||||
.with_ansi(false)
|
||||
.with_filter(file_filter);
|
||||
|
||||
tracing_subscriber::registry().with(file_layer).init();
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, ?LOG_FILENAME, "Could not create log file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use clap::Parser;
|
||||
use scopeguard::defer;
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
os::windows::{ffi::OsStringExt as _, io::AsRawHandle},
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::windows::named_pipe::{ClientOptions, NamedPipeClient},
|
||||
time,
|
||||
};
|
||||
use tracing::error;
|
||||
use windows::Win32::{
|
||||
Foundation::{CloseHandle, ERROR_PIPE_BUSY, HANDLE},
|
||||
System::{
|
||||
Pipes::GetNamedPipeServerProcessId,
|
||||
Threading::{
|
||||
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32,
|
||||
PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
},
|
||||
},
|
||||
UI::Shell::IsUserAnAdmin,
|
||||
};
|
||||
|
||||
use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME};
|
||||
|
||||
use super::{
|
||||
config::ENABLE_SERVER_SIGNATURE_VALIDATION,
|
||||
crypto::{
|
||||
decode_abe_key_blob, decode_base64, decrypt_with_dpapi_as_system,
|
||||
decrypt_with_dpapi_as_user, encode_base64,
|
||||
},
|
||||
log::init_logging,
|
||||
};
|
||||
use crate::dbg_log;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bitwarden_chromium_import_helper")]
|
||||
#[command(about = "Admin tool for ABE service management")]
|
||||
struct Args {
|
||||
#[arg(long, help = "Base64 encoded encrypted data string")]
|
||||
encrypted: String,
|
||||
}
|
||||
|
||||
async fn open_pipe_client(pipe_name: &'static str) -> Result<NamedPipeClient> {
|
||||
let max_attempts = 5;
|
||||
for _ in 0..max_attempts {
|
||||
match ClientOptions::new().open(pipe_name) {
|
||||
Ok(client) => {
|
||||
dbg_log!("Successfully connected to the pipe!");
|
||||
return Ok(client);
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => {
|
||||
dbg_log!("Pipe is busy, retrying in 50ms...");
|
||||
}
|
||||
Err(e) => {
|
||||
dbg_log!("Failed to connect to pipe: {}", &e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Failed to connect to pipe after {} attempts",
|
||||
max_attempts
|
||||
))
|
||||
}
|
||||
|
||||
async fn send_message_with_client(client: &mut NamedPipeClient, message: &str) -> Result<String> {
|
||||
client.write_all(message.as_bytes()).await?;
|
||||
|
||||
// Try to receive a response for this message
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
match client.read(&mut buffer).await {
|
||||
Ok(0) => Err(anyhow!(
|
||||
"Server closed the connection (0 bytes read) on message"
|
||||
)),
|
||||
Ok(bytes_received) => {
|
||||
let response = String::from_utf8_lossy(&buffer[..bytes_received]);
|
||||
Ok(response.to_string())
|
||||
}
|
||||
Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result<u32> {
|
||||
let handle = HANDLE(client.as_raw_handle() as _);
|
||||
let mut pid: u32 = 0;
|
||||
unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?;
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
fn resolve_process_executable_path(pid: u32) -> Result<PathBuf> {
|
||||
dbg_log!("Resolving process executable path for PID {}", pid);
|
||||
|
||||
// Open the process handle
|
||||
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
|
||||
dbg_log!("Opened process handle for PID {}", pid);
|
||||
|
||||
// Close when no longer needed
|
||||
defer! {
|
||||
dbg_log!("Closing process handle for PID {}", pid);
|
||||
unsafe {
|
||||
_ = CloseHandle(hprocess);
|
||||
}
|
||||
};
|
||||
|
||||
let mut exe_name = vec![0u16; 32 * 1024];
|
||||
let mut exe_name_length = exe_name.len() as u32;
|
||||
unsafe {
|
||||
QueryFullProcessImageNameW(
|
||||
hprocess,
|
||||
PROCESS_NAME_WIN32,
|
||||
windows::core::PWSTR(exe_name.as_mut_ptr()),
|
||||
&mut exe_name_length,
|
||||
)
|
||||
}?;
|
||||
dbg_log!(
|
||||
"QueryFullProcessImageNameW returned {} bytes",
|
||||
exe_name_length
|
||||
);
|
||||
|
||||
exe_name.truncate(exe_name_length as usize);
|
||||
Ok(PathBuf::from(OsString::from_wide(&exe_name)))
|
||||
}
|
||||
|
||||
async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) {
|
||||
_ = send_to_user(client, &format!("!{}", error_message)).await
|
||||
}
|
||||
|
||||
async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> {
|
||||
let _ = send_message_with_client(client, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_admin() -> bool {
|
||||
unsafe { IsUserAnAdmin().as_bool() }
|
||||
}
|
||||
|
||||
async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result<NamedPipeClient> {
|
||||
let client = open_pipe_client(pipe_name).await?;
|
||||
|
||||
if ENABLE_SERVER_SIGNATURE_VALIDATION {
|
||||
let server_pid = get_named_pipe_server_pid(&client)?;
|
||||
dbg_log!("Connected to pipe server PID {}", server_pid);
|
||||
|
||||
// Validate the server end process signature
|
||||
let exe_path = resolve_process_executable_path(server_pid)?;
|
||||
|
||||
dbg_log!("Pipe server executable path: {}", exe_path.display());
|
||||
|
||||
if !verify_signature(&exe_path)? {
|
||||
return Err(anyhow!("Pipe server signature is not valid"));
|
||||
}
|
||||
|
||||
dbg_log!("Pipe server signature verified for PID {}", server_pid);
|
||||
}
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn run() -> Result<String> {
|
||||
dbg_log!("Starting bitwarden_chromium_import_helper.exe");
|
||||
|
||||
let args = Args::try_parse()?;
|
||||
|
||||
if !is_admin() {
|
||||
return Err(anyhow!("Expected to run with admin privileges"));
|
||||
}
|
||||
|
||||
dbg_log!("Running as ADMINISTRATOR");
|
||||
|
||||
let encrypted = decode_base64(&args.encrypted)?;
|
||||
dbg_log!(
|
||||
"Decoded encrypted data [{}] {:?}",
|
||||
encrypted.len(),
|
||||
encrypted
|
||||
);
|
||||
|
||||
let system_decrypted = decrypt_with_dpapi_as_system(&encrypted)?;
|
||||
dbg_log!(
|
||||
"Decrypted data with DPAPI as SYSTEM {} {:?}",
|
||||
system_decrypted.len(),
|
||||
system_decrypted
|
||||
);
|
||||
|
||||
let user_decrypted = decrypt_with_dpapi_as_user(&system_decrypted, false)?;
|
||||
dbg_log!(
|
||||
"Decrypted data with DPAPI as USER {} {:?}",
|
||||
user_decrypted.len(),
|
||||
user_decrypted
|
||||
);
|
||||
|
||||
let key = decode_abe_key_blob(&user_decrypted)?;
|
||||
dbg_log!("Decoded ABE key blob {} {:?}", key.len(), key);
|
||||
|
||||
Ok(encode_base64(&key))
|
||||
}
|
||||
|
||||
pub(crate) async fn main() {
|
||||
init_logging();
|
||||
|
||||
let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to open pipe {} to send result/error: {}",
|
||||
ADMIN_TO_USER_PIPE_NAME, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match run() {
|
||||
Ok(system_decrypted_base64) => {
|
||||
dbg_log!("Sending response back to user");
|
||||
let _ = send_to_user(&mut client, &system_decrypted_base64).await;
|
||||
}
|
||||
Err(e) => {
|
||||
dbg_log!("Error: {}", e);
|
||||
send_error_to_user(&mut client, &format!("{}", e)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod impersonate;
|
||||
mod log;
|
||||
mod main;
|
||||
|
||||
pub(crate) use main::main;
|
||||
@@ -7,7 +7,7 @@ publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
aes = { workspace = true }
|
||||
aes-gcm = "=0.10.3"
|
||||
aes-gcm = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
async-trait = "=0.1.88"
|
||||
base64 = { workspace = true }
|
||||
@@ -22,24 +22,13 @@ serde_json = { workspace = true }
|
||||
sha1 = "=0.10.6"
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
chacha20poly1305 = { workspace = true }
|
||||
windows = { workspace = true, features = [
|
||||
"Wdk_System_SystemServices",
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Services",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
@@ -4,7 +4,7 @@ A rust library that allows you to directly import credentials from Chromium-base
|
||||
|
||||
## Windows ABE Architecture
|
||||
|
||||
On Windows chrome has additional protection measurements which needs to be circumvented in order to
|
||||
On Windows Chrome has additional protection measurements which needs to be circumvented in order to
|
||||
get access to the passwords.
|
||||
|
||||
### Overview
|
||||
@@ -25,7 +25,9 @@ encryption scheme for some local profiles.
|
||||
The general idea of this encryption scheme is as follows:
|
||||
|
||||
1. Chrome generates a unique random encryption key.
|
||||
2. This key is first encrypted at the **user level** with a fixed key.
|
||||
2. This key is first encrypted at the **user level** with a fixed key for v1/v2 of ABE. For ABE v3 a more complicated
|
||||
scheme is used that encrypts the key with a combination of a fixed key and a randomly generated key at the **system
|
||||
level** via Windows CNG API.
|
||||
3. It is then encrypted at the **user level** again using the Windows **Data Protection API (DPAPI)**.
|
||||
4. Finally, it is sent to a special service that encrypts it with DPAPI at the **system level**.
|
||||
|
||||
@@ -37,7 +39,7 @@ The following sections describe how the key is decrypted at each level.
|
||||
|
||||
This is a Rust module that is part of the Chromium importer. It compiles and runs only on Windows (see `abe.rs` and
|
||||
`abe_config.rs`). Its main task is to launch `bitwarden_chromium_import_helper.exe` with elevated privileges, presenting
|
||||
the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `windows.rs`.
|
||||
the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `platform/windows/mod.rs`.
|
||||
|
||||
This function takes two arguments:
|
||||
|
||||
@@ -75,10 +77,26 @@ With the duplicated token, `ImpersonateLoggedOnUser` is called to impersonate a
|
||||
|
||||
> **At this point `bitwarden_chromium_import_helper.exe` is running as SYSTEM.**
|
||||
|
||||
The received encryption key can now be decrypted using DPAPI at the system level.
|
||||
The received encryption key can now be decrypted using DPAPI at the **system level**.
|
||||
|
||||
The decrypted result is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to
|
||||
the pipe and writes the result.
|
||||
Next, the impersonation is stopped and the feshly decrypted key is decrypted at the **user level** with DPAPI one more
|
||||
time.
|
||||
|
||||
At this point, for browsers not using the custom encryption/obfuscation layer like unbranded Chromium, the twice
|
||||
decrypted key is the actual encryption key that could be used to decrypt the stored passwords.
|
||||
|
||||
For other browsers like Google Chrome, some additional processing is required. The decrypted key is actually a blob of structured data that could take multiple forms:
|
||||
|
||||
1. exactly 32 bytes: plain key, nothing to be done more in this case
|
||||
2. blob starts with 0x01: the key is encrypted with a fixed AES key found in Google Chrome binary, a random IV is stored
|
||||
in the blob as well
|
||||
3. blob starts with 0x02: the key is encrypted with a fixed ChaCha20 key found in Google Chrome binary, a random IV is
|
||||
stored in the blob as well
|
||||
4. blob starts with 0x03: the blob contains a random key, encrypted with CNG API with a random key stored in the
|
||||
**system keychain** under the name `Google Chromekey1`. After that key is decryped (under **system level** impersonation again), the key is xor'ed with a fixed key from the Chrome binary and the it is used to decrypt the key from the last DPAPI decryption stage.
|
||||
|
||||
The decrypted key is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to the
|
||||
pipe and writes the result.
|
||||
|
||||
The response can indicate success or failure:
|
||||
|
||||
@@ -92,17 +110,8 @@ Finally, `bitwarden_chromium_import_helper.exe` exits.
|
||||
|
||||
### 3. Back to the Client Library
|
||||
|
||||
The decrypted Base64-encoded string is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at
|
||||
the user level. At this point it has been decrypted only once—at the system level.
|
||||
|
||||
Next, the string is decrypted at the **user level** with DPAPI.
|
||||
|
||||
Finally, for Google Chrome (but not Brave), it is decrypted again with a hard-coded key found in `elevation_service.exe`
|
||||
from the Chrome installation. Based on the version of the encrypted string (encoded within the string itself), this step
|
||||
uses either **AES-256-GCM** or **ChaCha20-Poly1305**. See `windows.rs` for details.
|
||||
|
||||
After these steps, the master key is available and can be used to decrypt the password information stored in the
|
||||
browser’s local database.
|
||||
The decrypted Base64-encoded key is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at the
|
||||
user level. The key is used to decrypt the stored passwords and notes.
|
||||
|
||||
### TL;DR Steps
|
||||
|
||||
@@ -120,13 +129,12 @@ browser’s local database.
|
||||
2. Ensure `SE_DEBUG_PRIVILEGE` is enabled (not strictly necessary in tests).
|
||||
3. Impersonate a system process such as `services.exe` or `winlogon.exe`.
|
||||
4. Decrypt the key using DPAPI at the **SYSTEM** level.
|
||||
5. Decrypt it again with DPAPI at the **USER** level.
|
||||
6. (For Chrome only) Decrypt again with the hard-coded key, possibly at the **system level** again (see above).
|
||||
5. Send the result or error back via the named pipe.
|
||||
6. Exit.
|
||||
|
||||
3. **Back on the client side:**
|
||||
1. Receive the encryption key.
|
||||
1. Receive the master key.
|
||||
2. Shutdown the pipe server.
|
||||
3. Decrypt it with DPAPI at the **USER** level.
|
||||
4. (For Chrome only) Decrypt again with the hard-coded key.
|
||||
5. Obtain the fully decrypted master key.
|
||||
6. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc.
|
||||
3. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc.
|
||||
|
||||
@@ -2,7 +2,6 @@ use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use chacha20poly1305::ChaCha20Poly1305;
|
||||
use std::path::{Path, PathBuf};
|
||||
use windows::Win32::{
|
||||
Foundation::{LocalFree, HLOCAL},
|
||||
@@ -208,119 +207,8 @@ impl WindowsCryptoService {
|
||||
));
|
||||
}
|
||||
|
||||
let key_bytes = BASE64_STANDARD.decode(&key_base64)?;
|
||||
let key = unprotect_data_win(&key_bytes)?;
|
||||
|
||||
Self::decode_abe_key_blob(key.as_slice())
|
||||
}
|
||||
|
||||
fn decode_abe_key_blob(blob_data: &[u8]) -> Result<Vec<u8>> {
|
||||
let header_len = u32::from_le_bytes(blob_data[0..4].try_into()?) as usize;
|
||||
// Ignore the header
|
||||
|
||||
let content_len_offset = 4 + header_len;
|
||||
let content_len =
|
||||
u32::from_le_bytes(blob_data[content_len_offset..content_len_offset + 4].try_into()?)
|
||||
as usize;
|
||||
|
||||
if content_len < 1 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: content length is less than 1"
|
||||
));
|
||||
}
|
||||
|
||||
let content_offset = content_len_offset + 4;
|
||||
let content = &blob_data[content_offset..content_offset + content_len];
|
||||
|
||||
// When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge
|
||||
if content_len == 32 {
|
||||
return Ok(content.to_vec());
|
||||
}
|
||||
|
||||
let version = content[0];
|
||||
let key_blob = &content[1..];
|
||||
match version {
|
||||
// Google Chrome v1 key encrypted with a hardcoded AES key
|
||||
1_u8 => Self::decrypt_abe_key_blob_chrome_aes(key_blob),
|
||||
// Google Chrome v2 key encrypted with a hardcoded ChaCha20 key
|
||||
2_u8 => Self::decrypt_abe_key_blob_chrome_chacha20(key_blob),
|
||||
// Google Chrome v3 key encrypted with CNG APIs
|
||||
3_u8 => Self::decrypt_abe_key_blob_chrome_cng(key_blob),
|
||||
v => Err(anyhow!("Unsupported ABE key blob version: {}", v)),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: DRY up with decrypt_abe_key_blob_chrome_chacha20
|
||||
fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result<Vec<u8>> {
|
||||
if blob.len() < 60 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
|
||||
blob.len()
|
||||
));
|
||||
}
|
||||
|
||||
let iv: [u8; 12] = blob[0..12].try_into()?;
|
||||
let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?;
|
||||
|
||||
const GOOGLE_AES_KEY: &[u8] = &[
|
||||
0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93,
|
||||
0x66, 0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D,
|
||||
0xA0, 0x28, 0x47, 0x87,
|
||||
];
|
||||
let aes_key = Key::<Aes256Gcm>::from_slice(GOOGLE_AES_KEY);
|
||||
let cipher = Aes256Gcm::new(aes_key);
|
||||
|
||||
let decrypted = cipher
|
||||
.decrypt((&iv).into(), ciphertext.as_ref())
|
||||
.map_err(|e| anyhow!("Failed to decrypt v20 key with Google AES key: {}", e))?;
|
||||
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result<Vec<u8>> {
|
||||
if blob.len() < 60 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
|
||||
blob.len()
|
||||
));
|
||||
}
|
||||
|
||||
let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY);
|
||||
let cipher = ChaCha20Poly1305::new(chacha20_key);
|
||||
|
||||
const GOOGLE_CHACHA20_KEY: &[u8] = &[
|
||||
0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25,
|
||||
0x80, 0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D,
|
||||
0x08, 0x72, 0x96, 0x60,
|
||||
];
|
||||
|
||||
let iv: [u8; 12] = blob[0..12].try_into()?;
|
||||
let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?;
|
||||
|
||||
let decrypted = cipher
|
||||
.decrypt((&iv).into(), ciphertext.as_ref())
|
||||
.map_err(|e| anyhow!("Failed to decrypt v20 key with Google ChaCha20 key: {}", e))?;
|
||||
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result<Vec<u8>> {
|
||||
if blob.len() < 92 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: expected at least 92 bytes, got {} bytes",
|
||||
blob.len()
|
||||
));
|
||||
}
|
||||
|
||||
let _encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?;
|
||||
let _iv: [u8; 12] = blob[32..32 + 12].try_into()?;
|
||||
let _ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?;
|
||||
|
||||
// TODO: Decrypt the AES key using CNG APIs
|
||||
// TODO: Implement this in the future once we run into a browser that uses this scheme
|
||||
|
||||
// There's no way to test this at the moment. This encryption scheme is not used in any of the browsers I've tested.
|
||||
Err(anyhow!("Google ABE CNG flavor is not supported yet"))
|
||||
let key = BASE64_STANDARD.decode(&key_base64)?;
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user