dermesser / leveldb-rs

A reimplementation of LevelDB in Rust (no bindings).
Other
521 stars 59 forks source link

Chrome/Electron localStorage is custom encoded #35

Closed ShayBox closed 1 year ago

ShayBox commented 1 year ago

Update

After many hours of trying to decode JSON from LevelDB from Chrome/Electron, I figured out the encoding

use std::{fs::File, io::Write};

use rusty_leveldb::{CompressionType, Options, DB};

fn main() -> anyhow::Result<()> {
    println!("Hello, world!");

    let mut db = DB::open(
        "leveldb",
        Options {
            compression_type: CompressionType::CompressionSnappy,
            create_if_missing: false,
            paranoid_checks: true,
            ..Default::default()
        },
    )?;

    let Some(data) = db.get(b"_file://\x00\x01persist:root") else {
        anyhow::bail!("None - Data not found");
    };

    let text = decode(&data);
    println!("Data Length: {}", data.len());
    println!("Text Length: {}", text.len());

    if let Ok(mut file) = File::create("data.json") {
        file.write_all(text.as_bytes())?;
    };

    println!("Goodbye, world!");

    Ok(())
}

fn decode(data: &[u8]) -> String {
    let mut decoded_string = String::new();
    let mut index = 0;

    while index < data.len() {
        if data[index] == 0 {
            let start_index = index + 1;

            while index + 1 < data.len() && (data[index + 1] != 0 || index % 2 != 0) {
                index += 1;
            }

            let end_index = index;
            let encoded_slice = &data[start_index..=end_index];

            if encoded_slice.len() >= 2 && encoded_slice.len() % 2 == 0 {
                let decoded_bytes: Vec<u16> = encoded_slice
                    .chunks_exact(2)
                    .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
                    .collect();

                if let Ok(decoded) = String::from_utf16(&decoded_bytes) {
                    decoded_string.push_str(&decoded);
                }
            }
        }

        index += 1;
    }

    decoded_string
}

I found this blog post that helped point me in the right direction, but unfortunately, my data was not compressed with lz-string like Slack.

ShayBox commented 1 year ago

Update

/// https://github.com/cclgroupltd/ccl_chrome_indexeddb
fn decode_string(bytes: &[u8]) -> Result<Cow<'_, str>> {
    let prefix = bytes.first().ok_or(anyhow!("No prefix found"))?;
    match prefix {
        0 => Ok(UTF_16LE.decode(&bytes[1..]).0),
        1 => Ok(WINDOWS_1252.decode(&bytes[1..]).0),
        _ => bail!("Invalid prefix"),
    }
}
dermesser commented 1 year ago

Would you like to contribute this as another example in the examples directory? For example a tool to show a nice overview of what's stored in a local storage database.

ShayBox commented 1 year ago

Sure, this is the mostly final code from my project, which serializes data with serde_json
The path code would need to be changed to point to a browser's leveldb and the struct fields changed
You could also skip the struct and parse directly to a dynamic serde_json::Value and iterate over the db

use std::{borrow::Cow, path::PathBuf};

use anyhow::{anyhow, bail, Result};
use encoding_rs::{UTF_16LE, WINDOWS_1252};
use rusty_leveldb::{CompressionType, Options, DB};
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Wootility {
    #[serde(rename = "_persist")]
    pub persist: Value,
    pub profiles: Value,
    #[serde(rename = "wootilityConfig")]
    pub wootility_config: Value,
}

impl Wootility {
    pub fn get_path() -> Result<PathBuf> {
        ["", "-beta", "-alpha"]
            .into_iter()
            .map(|path| format!("wootility-lekker{path}/Local Storage/leveldb"))
            .map(|path| dirs::config_dir().unwrap().join(path))
            .find(|path| path.exists())
            .ok_or(anyhow!("Couldn't find Wootility path"))
    }

    pub fn load() -> Result<Self> {
        let mut db = DB::open(
            Self::get_path()?,
            Options {
                compression_type: CompressionType::CompressionSnappy,
                create_if_missing: false,
                paranoid_checks: true,
                ..Default::default()
            },
        )?;

        const KEY: &[u8; 22] = b"_file://\x00\x01persist:root";
        let encoded = db.get(KEY).ok_or(anyhow!("Couldn't find Wootility data"))?;
        let decoded = Self::decode_string(&encoded)?;

        Ok(serde_json::from_str(&decoded)?)
    }

    /// https://github.com/cclgroupltd/ccl_chrome_indexeddb
    pub fn decode_string(bytes: &[u8]) -> Result<Cow<'_, str>> {
        let prefix = bytes.first().ok_or(anyhow!("Invalid length"))?;
        match prefix {
            0 => Ok(UTF_16LE.decode(&bytes[1..]).0),
            1 => Ok(WINDOWS_1252.decode(&bytes[1..]).0),
            _ => bail!("Invalid prefix"),
        }
    }
}

fn main() -> Result<()> {
    let wootility = Wootility::load()?;
    println!("{wootility:#?}");

    Ok(())
}