From e5147b22835413198ba0993d9045c6a639168b6a Mon Sep 17 00:00:00 2001 From: christopher-buss Date: Sun, 29 Jun 2025 23:51:47 +0100 Subject: [PATCH] feat(rbx_cookie): add wsl cookie support for windows credential manager Add support for accessing Roblox Studio cookies from WSL environments by calling Windows Credential Manager and Registry APIs via PowerShell. - Add wsl_windows module with secure PowerShell execution using -EncodedCommand - Implement credential discovery via cmdkey and P/Invoke CredRead API Enables Mantle deployment from WSL environments where Roblox Studio is installed on the Windows host. --- mantle/Cargo.lock | 1 + mantle/rbx_cookie/Cargo.toml | 3 + mantle/rbx_cookie/src/lib.rs | 38 ++++- mantle/rbx_cookie/src/wsl_windows.rs | 225 +++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 mantle/rbx_cookie/src/wsl_windows.rs diff --git a/mantle/Cargo.lock b/mantle/Cargo.lock index cd77744..48cf185 100644 --- a/mantle/Cargo.lock +++ b/mantle/Cargo.lock @@ -2494,6 +2494,7 @@ dependencies = [ name = "rbx_cookie" version = "0.1.5" dependencies = [ + "base64 0.22.1", "byteorder 0.5.3", "clap 2.34.0", "cookie 0.15.2", diff --git a/mantle/rbx_cookie/Cargo.toml b/mantle/rbx_cookie/Cargo.toml index 79f1929..9717f46 100644 --- a/mantle/rbx_cookie/Cargo.toml +++ b/mantle/rbx_cookie/Cargo.toml @@ -24,6 +24,9 @@ clap = { version = "2.33.0", optional = true } winreg = "0.10.1" winapi = { version = "0.3.9", features = ["wincred", "impl-default"] } +[target.'cfg(all(target_os = "linux", not(target_os = "android")))'.dependencies] +base64 = "0.22.1" + [target.'cfg(target_os = "macos")'.dependencies] plist = "1.3.1" byteorder = "0.5.3" diff --git a/mantle/rbx_cookie/src/lib.rs b/mantle/rbx_cookie/src/lib.rs index 56848b9..27dc5ac 100644 --- a/mantle/rbx_cookie/src/lib.rs +++ b/mantle/rbx_cookie/src/lib.rs @@ -4,6 +4,9 @@ mod wincred; #[cfg(target_os = "macos")] mod binarycookies; +#[cfg(all(target_os = "linux", not(target_os = "android")))] +pub mod wsl_windows; + use std::env; use cookie::Cookie; @@ -111,6 +114,18 @@ fn from_roblox_studio() -> Option { #[cfg(not(any(target_os = "windows", target_os = "macos")))] fn from_roblox_studio() -> Option { + #[cfg(all(target_os = "linux", not(target_os = "android")))] + { + if wsl_windows::is_wsl() { + trace!("WSL detected, attempting to load cookie from Windows Credentials."); + + if let Some(cookie) = wsl_windows::get_roblosecurity_cookie() { + info!("Loaded cookie from Windows Credentials via WSL."); + return Some(cookie); + } + } + } + None } @@ -164,10 +179,31 @@ fn from_roblox_studio_legacy() -> Option { #[cfg(not(any(target_os = "windows", target_os = "macos")))] fn from_roblox_studio_legacy() -> Option { + #[cfg(all(target_os = "linux", not(target_os = "android")))] + { + if wsl_windows::is_wsl() { + trace!("WSL detected, attempting to load cookie from Windows Registry via WSL."); + + let value = wsl_windows::read_registry( + "SOFTWARE\\Roblox\\RobloxStudioBrowser\\roblox.com", + COOKIE_NAME, + )?; + + if let Some(cookie) = parse_roblox_studio_cookie(&value) { + info!("Loaded cookie from Windows Registry via WSL."); + return Some(cookie); + } + } + } + None } -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(any( + target_os = "windows", + target_os = "macos", + all(target_os = "linux", not(target_os = "android")) +))] fn parse_roblox_studio_cookie(value: &str) -> Option { for item in value.split(',') { let parts = item.split("::").collect::>(); diff --git a/mantle/rbx_cookie/src/wsl_windows.rs b/mantle/rbx_cookie/src/wsl_windows.rs new file mode 100644 index 0000000..dc1fdd4 --- /dev/null +++ b/mantle/rbx_cookie/src/wsl_windows.rs @@ -0,0 +1,225 @@ +#[cfg(all(target_os = "linux", not(target_os = "android")))] +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use log::{debug, trace}; +use std::process::Command; + +pub fn is_wsl() -> bool { + std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() +} + +fn get_roblox_credential_targets() -> Vec { + trace!("Getting all Roblox credential targets from Windows host"); + + let output = match Command::new("cmd.exe") + .args(["/c", "cmdkey /list | findstr roblox"]) + .output() + { + Ok(output) if output.status.success() => output, + _ => { + debug!("Failed to get credential targets"); + return Vec::new(); + } + }; + let targets: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let trimmed_line = line.trim(); + if trimmed_line.starts_with("Target: LegacyGeneric:target=") { + Some(trimmed_line.strip_prefix("Target: ").unwrap().to_string()) + } else { + None + } + }) + .collect(); + + debug!("Found {} Roblox credential targets", targets.len()); + targets +} + +/// PowerShell script template for reading Windows credentials +/// This uses P/Invoke to call the Windows Credential Manager API directly +const CREDENTIAL_READER_SCRIPT: &str = r#" +$ErrorActionPreference = 'SilentlyContinue' +$VerbosePreference = 'SilentlyContinue' +$WarningPreference = 'SilentlyContinue' + +# Define P/Invoke signatures for Windows Credential Manager +Add-Type @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +public class CredMan { + [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] + public static extern bool CredRead( + string target, + int type, + int reservedFlag, + out IntPtr credentialPtr + ); + + [DllImport("advapi32.dll")] + public static extern bool CredFree([In] IntPtr cred); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct CREDENTIAL { + public int Flags; + public int Type; + public IntPtr TargetName; + public IntPtr Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public IntPtr TargetAlias; + public IntPtr UserName; + } +} +'@ *> $null + +# Try to read the credential +try { + $credPtr = [IntPtr]::Zero + $success = [CredMan]::CredRead('TARGET_PLACEHOLDER', 1, 0, [ref]$credPtr) + + if ($success) { + $cred = [System.Runtime.InteropServices.Marshal]::PtrToStructure( + $credPtr, + [type][CredMan+CREDENTIAL] + ) + + # Extract password bytes from the credential blob + $passwordBytes = [byte[]]::new($cred.CredentialBlobSize) + [System.Runtime.InteropServices.Marshal]::Copy( + $cred.CredentialBlob, + $passwordBytes, + 0, + $cred.CredentialBlobSize + ) + + # Convert bytes to string and output + $password = [System.Text.Encoding]::UTF8.GetString($passwordBytes) + [CredMan]::CredFree($credPtr) + Write-Host $password -NoNewline + } +} catch { } +"#; + +fn get_credential(target: &str) -> Option { + trace!( + "Attempting to read credential '{}' from Windows host", + target + ); + + let script = CREDENTIAL_READER_SCRIPT.replace("TARGET_PLACEHOLDER", target); + + // PowerShell requires commands to be encoded as UTF-16 Little Endian, then Base64. + // This is the most reliable way to pass a complex script from any shell (like WSL's bash). + let utf16_script: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let encoded_script = STANDARD.encode(utf16_script); + + let output = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + &encoded_script, + ]) + .output() + .ok()?; + + if !output.status.success() { + trace!("PowerShell command failed for credential '{}'", target); + return None; + } + + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !result.is_empty() { + debug!("Successfully retrieved credential '{}'", target); + Some(result) + } else { + trace!("Failed to retrieve credential '{}' (empty result)", target); + None + } +} + +fn find_credential_by_pattern(pattern: &str) -> Option { + let targets = get_roblox_credential_targets(); + targets + .iter() + .find(|target| target.contains(pattern)) + .and_then(|target| get_credential(target)) +} + +fn get_user_id() -> Option { + trace!("Looking for Roblox user ID in credential manager"); + + if let Some(user_id) = find_credential_by_pattern("RobloxStudioAuthuserid") { + debug!("Found user ID: {}", user_id); + Some(user_id) + } else { + trace!("No user ID found in credential manager"); + None + } +} + +pub fn get_roblosecurity_cookie() -> Option { + trace!("Looking for ROBLOSECURITY cookie in credential manager"); + + // First try to get user-specific cookie + if let Some(user_id) = get_user_id() { + debug!("Looking for user-specific cookie for user ID: {}", user_id); + if let Some(cookie) = + find_credential_by_pattern(&format!("RobloxStudioAuth.ROBLOSECURITY{}", user_id)) + { + debug!("Found user-specific ROBLOSECURITY cookie"); + return Some(cookie); + } + } + + // Fallback to any ROBLOSECURITY cookie + debug!("Looking for any ROBLOSECURITY cookie"); + if let Some(cookie) = find_credential_by_pattern("RobloxStudioAuth.ROBLOSECURITY") { + debug!("Found fallback ROBLOSECURITY cookie"); + Some(cookie) + } else { + trace!("No ROBLOSECURITY cookie found in credential manager"); + None + } +} + +/// Read a value from Windows Registry via reg.exe +pub fn read_registry(key_path: &str, value_name: &str) -> Option { + trace!( + "Attempting to read registry key '{}\\{}' from Windows host", + key_path, + value_name + ); + + let output = Command::new("reg.exe") + .args(["query", &format!("HKCU\\{}", key_path), "/v", value_name]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| { + // Split the line by whitespace. The typical format is: + // + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 && parts[0] == value_name && parts[1] == "REG_SZ" { + // Join the remaining parts to handle multi-word values + let value = parts[2..].join(" "); + debug!("Successfully read registry value"); + Some(value) + } else { + None + } + }) +}