adhammer 1.3.9

ADhammer — Active Directory security assessment and offensive tradecraft in Rust, with a from-scratch DCE/RPC · NTLM · SMB2 · Kerberos stack.
//! Saved engagement profile — written on first `adhammer` run, reused with `--old`.
//! On Windows the session file is DPAPI-encrypted (CryptProtectData) so creds at rest
//! are bound to the current user's login session. On Unix it's chmod 600.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

use crate::ScanArgs;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
    /// DNS domain, e.g. `corp.local`
    pub domain: String,
    /// Domain controller hostname or IP, e.g. `dc.corp.local`
    pub dc: String,
    /// Bind username (sAMAccountName or DOMAIN\\user)
    pub username: String,
    pub password: String,
    /// Optional NT hash (32 hex) for pass-the-hash on the SMB-based actions.
    #[serde(default)]
    pub nt_hash: Option<String>,
    /// Skip TLS verification for lab LDAPS
    #[serde(default)]
    pub insecure: bool,
}

impl Session {
    pub fn realm(&self) -> String {
        self.domain.to_uppercase()
    }

    pub fn netbios(&self) -> String {
        self.domain
            .split('.')
            .next()
            .unwrap_or(self.domain.as_str())
            .to_uppercase()
    }

    pub fn ldap_url(&self) -> String {
        format!("ldaps://{}:636", self.dc)
    }

    pub fn scan_args(&self) -> ScanArgs {
        ScanArgs {
            url: self.ldap_url(),
            user: self.username.clone(),
            password: self.password.clone(),
            base_dn: None,
            format: "json".to_string(),
            kdc: Some(self.dc.clone()),
            sysvol: None,
            insecure: self.insecure,
            gssapi: false,
            bloodhound: None,
        }
    }
}

fn config_path() -> Result<PathBuf> {
    let base = if cfg!(windows) {
        std::env::var("APPDATA").context("APPDATA not set")?
    } else {
        std::env::var("HOME")
            .or_else(|_| std::env::var("USERPROFILE"))
            .context("HOME not set")?
            .parse::<PathBuf>()
            .context("bad home path")?
            .join(".config")
            .to_string_lossy()
            .into_owned()
    };
    Ok(PathBuf::from(base).join("adhammer").join("session.json"))
}

pub fn load() -> Result<Session> {
    let path = config_path()?;
    let raw = std::fs::read(&path).with_context(|| {
        format!(
            "no saved session at {} — run `adhammer` first",
            path.display()
        )
    })?;
    let json = dpapi::decrypt_if_wrapped(&raw)?;
    serde_json::from_slice(&json).context("parse saved session")
}

pub fn save(session: &Session) -> Result<()> {
    let path = config_path()?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string_pretty(session)?;
    let blob = dpapi::encrypt(json.as_bytes())?;
    std::fs::write(&path, &blob)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
    }
    eprintln!("[*] session saved to {}", path.display());
    Ok(())
}

pub fn exists() -> bool {
    config_path().map(|p| p.is_file()).unwrap_or(false)
}

/// Delete the saved session file (creds) from disk. Idempotent — a missing file is not an error.
pub fn wipe() -> Result<()> {
    let path = config_path()?;
    match std::fs::remove_file(&path) {
        Ok(()) => eprintln!("[*] session wiped: {}", path.display()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            eprintln!("[*] no saved session to wipe ({})", path.display())
        }
        Err(e) => return Err(e).context("wipe session"),
    }
    Ok(())
}

mod dpapi {
    use anyhow::{Context, Result};

    const MAGIC: &[u8; 4] = b"ADHS";

    #[cfg(windows)]
    mod win {
        use std::ptr;

        #[repr(C)]
        struct DataBlob {
            cb_data: u32,
            pb_data: *mut u8,
        }

        extern "system" {
            fn CryptProtectData(
                data_in: *const DataBlob,
                description: *const u16,
                entropy: *const DataBlob,
                reserved: *mut u8,
                prompt: *mut u8,
                flags: u32,
                data_out: *mut DataBlob,
            ) -> i32;

            fn CryptUnprotectData(
                data_in: *const DataBlob,
                description: *mut *mut u16,
                entropy: *const DataBlob,
                reserved: *mut u8,
                prompt: *mut u8,
                flags: u32,
                data_out: *mut DataBlob,
            ) -> i32;

            fn LocalFree(mem: *mut u8) -> *mut u8;
        }

        pub fn protect(plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
            let input = DataBlob {
                cb_data: plaintext.len() as u32,
                pb_data: plaintext.as_ptr() as *mut u8,
            };
            let mut output = DataBlob {
                cb_data: 0,
                pb_data: ptr::null_mut(),
            };
            let ok = unsafe {
                CryptProtectData(
                    &input,
                    ptr::null(),
                    ptr::null(),
                    ptr::null_mut(),
                    ptr::null_mut(),
                    0,
                    &mut output,
                )
            };
            if ok == 0 {
                anyhow::bail!("CryptProtectData failed (GetLastError)");
            }
            let enc =
                unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize) }
                    .to_vec();
            unsafe { LocalFree(output.pb_data) };
            Ok(enc)
        }

        pub fn unprotect(ciphertext: &[u8]) -> anyhow::Result<Vec<u8>> {
            let input = DataBlob {
                cb_data: ciphertext.len() as u32,
                pb_data: ciphertext.as_ptr() as *mut u8,
            };
            let mut output = DataBlob {
                cb_data: 0,
                pb_data: ptr::null_mut(),
            };
            let ok = unsafe {
                CryptUnprotectData(
                    &input,
                    ptr::null_mut(),
                    ptr::null(),
                    ptr::null_mut(),
                    ptr::null_mut(),
                    0,
                    &mut output,
                )
            };
            if ok == 0 {
                anyhow::bail!(
                    "CryptUnprotectData failed — session file may belong to another user"
                );
            }
            let dec =
                unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize) }
                    .to_vec();
            unsafe { LocalFree(output.pb_data) };
            Ok(dec)
        }
    }

    pub fn encrypt(plaintext: &[u8]) -> Result<Vec<u8>> {
        #[cfg(windows)]
        {
            let enc = win::protect(plaintext).context("DPAPI encrypt")?;
            let mut out = Vec::with_capacity(4 + enc.len());
            out.extend_from_slice(MAGIC);
            out.extend_from_slice(&enc);
            Ok(out)
        }
        #[cfg(not(windows))]
        {
            eprintln!("[!] session creds stored unencrypted (DPAPI unavailable on this OS)");
            Ok(plaintext.to_vec())
        }
    }

    pub fn decrypt_if_wrapped(data: &[u8]) -> Result<Vec<u8>> {
        if data.starts_with(MAGIC) {
            #[cfg(windows)]
            {
                return win::unprotect(&data[4..]).context("DPAPI decrypt");
            }
            #[cfg(not(windows))]
            {
                anyhow::bail!(
                    "session file is DPAPI-encrypted (created on Windows) — cannot decrypt on this OS"
                );
            }
        }
        Ok(data.to_vec())
    }
}