use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::LazyLock;
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator, Tree};
use crate::security::detect::ShellContext;
use crate::security::detect::powershell::ast::{
PsAstState, capture_by_name, get_command_name, language,
};
use crate::security::detect::utils::node_extract_text;
pub const MAX_DEOBF_DEPTH: usize = 512;
pub const MAX_DEOBF_TOTAL_BYTES: usize = 512 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ObfuscationTechnique {
BacktickEscape,
PsStringConcatenation,
PsVariableSubstitution,
PsEncodedCommand,
PsBase64Convert,
PsInvokeExpression,
NestedPsInvocation,
PsSubExpression,
PsCallOperator,
UnresolvedDynamic,
}
#[derive(Debug, Clone, Default)]
pub struct DeobfMeta {
pub techniques: Vec<ObfuscationTechnique>,
pub decode_chain: Vec<ObfuscationTechnique>,
pub raw_source: Option<String>,
}
#[derive(PartialEq)]
enum WordKind {
Name,
Arg,
}
struct WordSpec {
start_byte: usize,
end_byte: usize,
kind: WordKind,
}
fn unescape_ps(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(c) = chars.next() {
if c == '`' {
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('a') => out.push('\x07'),
Some('b') => out.push('\x08'),
Some('f') => out.push('\x0c'),
Some('v') => out.push('\x0b'),
Some('0') => out.push('\0'),
Some('`') => out.push('`'),
Some(other) => out.push(other),
None => out.push('`'),
}
} else {
out.push(c);
}
}
out
}
fn contains_variable_ref(text: &str) -> bool {
text.contains('$')
}
async fn substitute_vars_inner(raw: &str, ctx: &ShellContext) -> (String, bool) {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
let mut changed = false;
while let Some(c) = chars.next() {
if c == '$' {
let next = chars.peek().copied();
match next {
Some('(') => {
out.push_str("$(");
chars.next();
continue;
}
Some('{') => {
let mut body = String::new();
for ch in chars.by_ref() {
if ch == '}' {
break;
}
body.push(ch);
}
match lookup_variable(&body, ctx).await {
Some(v) => {
out.push_str(&v);
changed = true;
}
None => {
out.push_str("${");
out.push_str(&body);
out.push('}');
}
}
}
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
let mut name = String::new();
name.push(c);
chars.next();
while let Some(ch) = chars.peek() {
if ch.is_ascii_alphanumeric() || *ch == '_' {
name.push(*ch);
chars.next();
} else {
break;
}
}
match lookup_variable(&name, ctx).await {
Some(v) => {
out.push_str(&v);
changed = true;
}
None => {
out.push('$');
out.push_str(&name);
}
}
}
_ => {
out.push('$');
}
}
} else {
out.push(c);
}
}
(out, changed)
}
async fn lookup_variable(name: &str, ctx: &ShellContext) -> Option<String> {
let (scope, key) = match name.split_once(':') {
Some(("env", k)) => ("env", k.to_string()),
Some((_, k)) => ("var", k.to_string()),
None => ("var", name.to_string()),
};
if scope == "env" {
if let Some(v) = ctx.env_get(&key).await {
return Some(v);
}
return None;
}
if let Some(v) = ctx.var.get(name).await
&& let Some(s) = v.as_str()
{
return Some(s.to_string());
}
ctx.env_get(name).await
}
fn collect_word_specs(tree: &Tree, source: &[u8]) -> Vec<(WordSpec, String)> {
static QUERY: LazyLock<Query> =
LazyLock::new(|| Query::new(&language(), "(command) @cmd").expect("invalid query"));
let mut cursor = QueryCursor::new();
let mut specs: Vec<(WordSpec, String)> = Vec::new();
let mut matches = cursor.matches(&QUERY, tree.root_node(), source);
while let Some(m) = StreamingIterator::next(&mut matches) {
let Some(cmd) = capture_by_name(&QUERY, m, "cmd") else {
continue;
};
if has_ancestor_kind(&cmd, "sub_expression") {
continue;
}
let mut cc = cmd.walk();
let mut seen_elements = false;
for child in cmd.children(&mut cc) {
if child.kind() == "command_name"
|| child.kind() == "command_name_expr"
|| child.kind() == "path_command_name"
{
if let Some(t) = node_extract_text(&child, source) {
specs.push((
WordSpec {
start_byte: child.start_byte(),
end_byte: child.end_byte(),
kind: WordKind::Name,
},
t.to_string(),
));
}
} else if child.kind() == "command_elements" {
seen_elements = true;
let mut ec = child.walk();
for el in child.children(&mut ec) {
match el.kind() {
"generic_token" | "command_parameter" | "string_literal"
| "expandable_string_literal" | "verbatim_string_characters"
| "verbatim_here_string_characters" | "expandable_here_string_literal" => {
if let Some(t) = node_extract_text(&el, source) {
specs.push((
WordSpec {
start_byte: el.start_byte(),
end_byte: el.end_byte(),
kind: WordKind::Arg,
},
t.to_string(),
));
}
}
_ => {
}
}
}
} else if !seen_elements && child.kind() != "command_invokation_operator" {
}
}
}
specs
}
fn has_ancestor_kind(node: &Node, kind: &str) -> bool {
let mut cur = node.parent();
while let Some(p) = cur {
if p.kind() == kind {
return true;
}
cur = p.parent();
}
false
}
fn fold_additive_strings(tree: &Tree, source: &[u8]) -> Vec<(usize, usize, String)> {
static QUERY: LazyLock<Query> = LazyLock::new(|| {
Query::new(
&language(),
"[(additive_expression) @add (additive_argument_expression) @add]",
)
.expect("invalid query")
});
let mut cursor = QueryCursor::new();
let mut out = Vec::new();
let mut matches = cursor.matches(&QUERY, tree.root_node(), source);
while let Some(m) = StreamingIterator::next(&mut matches) {
let Some(node) = capture_by_name(&QUERY, m, "add") else {
continue;
};
if has_ancestor_kind(&node, "sub_expression") {
continue;
}
if let Some(folded) = fold_one(&node, source) {
out.push((node.start_byte(), node.end_byte(), folded));
}
}
out
}
fn fold_one(node: &Node, source: &[u8]) -> Option<String> {
let mut parts = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"string_literal" => {
let t = node_extract_text(&child, source)?;
parts.push(clean_ps_string(t));
}
"additive_expression" | "additive_argument_expression" => {
let t = fold_one(&child, source)?;
parts.push(t);
}
_ => return None,
}
}
if parts.is_empty() {
None
} else {
Some(parts.concat())
}
}
pub fn clean_ps_string(s: &str) -> String {
let s = s.trim();
if s.len() >= 2 {
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
return s[1..s.len() - 1].to_string();
}
}
s.to_string()
}
async fn build_normalized_source(
tree: &Tree,
source: &[u8],
ctx: &ShellContext,
) -> (String, Vec<ObfuscationTechnique>) {
let words = collect_word_specs(tree, source);
let concats = fold_additive_strings(tree, source);
let mut edits: Vec<(usize, usize, String)> = Vec::new();
let mut techs: Vec<ObfuscationTechnique> = Vec::new();
for (spec, text) in &words {
let mut new_text = unescape_ps(text);
if spec.kind == WordKind::Name {
if new_text != *text {
techs.push(ObfuscationTechnique::BacktickEscape);
}
} else if new_text != *text {
techs.push(ObfuscationTechnique::BacktickEscape);
}
if matches!(spec.kind, WordKind::Arg) && contains_variable_ref(text) {
let inner = trim_quote_pair(&new_text);
let (subbed, changed) = substitute_vars_inner(&inner, ctx).await;
if changed {
let re = match new_text.chars().next() {
Some('"') => format!("\"{subbed}\""),
_ => subbed,
};
new_text = re;
techs.push(ObfuscationTechnique::PsVariableSubstitution);
}
}
if new_text != *text {
edits.push((spec.start_byte, spec.end_byte, new_text));
}
}
for (start, end, folded) in concats {
edits.push((start, end, folded));
techs.push(ObfuscationTechnique::PsStringConcatenation);
}
if edits.is_empty() {
return (String::from_utf8_lossy(source).into_owned(), Vec::new());
}
edits.sort_by_key(|e| e.0);
let mut out = String::with_capacity(source.len());
let mut last = 0usize;
for (start, end, repl) in edits {
if start < last {
continue;
}
out.push_str(std::str::from_utf8(&source[last..start]).unwrap_or(""));
out.push_str(&repl);
last = end;
}
out.push_str(std::str::from_utf8(&source[last..]).unwrap_or(""));
(out, techs)
}
fn trim_quote_pair(s: &str) -> &str {
let s = s.trim();
if s.len() >= 2
&& ((s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\'')))
{
&s[1..s.len() - 1]
} else {
s
}
}
fn base64_decode_bytes(input: &str) -> Option<Vec<u8>> {
fn val(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let bytes: Vec<u8> = input.bytes().filter(|&b| b != b'=').collect();
if bytes.is_empty() {
return None;
}
let mut out = Vec::with_capacity(bytes.len() * 3 / 4 + 3);
let mut buf: u32 = 0;
let mut bits: u32 = 0;
for b in bytes {
let v = val(b)?;
buf = (buf << 6) | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(((buf >> bits) & 0xFF) as u8);
}
}
Some(out)
}
fn decode_ps_base64(s: &str) -> Option<String> {
let clean: String = s.chars().filter(|c| !c.is_whitespace()).collect();
let bytes = base64_decode_bytes(&clean)?;
if bytes.len() >= 2 {
if let Some(text) = decode_utf16le(&bytes) {
if !text.trim().is_empty() {
return Some(text);
}
}
}
String::from_utf8(bytes).ok()
}
fn decode_utf16le(bytes: &[u8]) -> Option<String> {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
let units = if units.first() == Some(&0xFEFF) {
&units[1..]
} else {
&units[..]
};
String::from_utf16(units).ok()
}
fn extract_sink_argument<'a>(cmd: &Node<'a>) -> Option<Node<'a>> {
let mut cc = cmd.walk();
for child in cmd.children(&mut cc) {
if child.kind() == "command_elements" {
let mut ec = child.walk();
for el in child.children(&mut ec) {
match el.kind() {
"command_parameter" | "command_argument_sep" | "redirection" => continue,
_ => return Some(el),
}
}
}
}
None
}
fn collect_elements<'a>(cmd: &Node<'a>, source: &[u8]) -> Vec<(String, Node<'a>)> {
let mut out = Vec::new();
let mut cc = cmd.walk();
for child in cmd.children(&mut cc) {
if child.kind() == "command_elements" {
let mut ec = child.walk();
for el in child.children(&mut ec) {
let text = node_extract_text(&el, source).unwrap_or("").to_string();
out.push((text, el));
}
}
}
out
}
fn extract_encoded_command_arg<'a>(
elements: &[(String, Node<'a>)],
) -> Option<Node<'a>> {
for (i, (text, _)) in elements.iter().enumerate() {
let lower = text.to_lowercase();
if lower == "-enc" || lower == "-encodedcommand" || lower == "-e" {
return elements.get(i + 1).map(|(_, n)| *n);
}
}
None
}
fn walk_for_sinks(tree: &Tree, source: &[u8], out: &mut Vec<(String, ObfuscationTechnique)>) {
let mut stack = vec![tree.root_node()];
while let Some(n) = stack.pop() {
match n.kind() {
"command" => {
if let Some(name) = get_command_name(&n, source) {
let lower = name.to_lowercase();
let elements = collect_elements(&n, source);
match lower.as_str() {
"iex" | "invoke-expression" => {
if let Some(arg) = extract_sink_argument(&n)
&& let Some(text) = node_extract_text(&arg, source)
&& !text.trim().is_empty()
{
out.push((
text.to_string(),
ObfuscationTechnique::PsInvokeExpression,
));
}
}
"powershell" | "pwsh" | "powershell.exe" | "pwsh.exe" => {
if let Some(arg) = extract_encoded_command_arg(&elements)
&& let Some(text) = node_extract_text(&arg, source)
&& let Some(decoded) = decode_ps_base64(text)
&& !decoded.trim().is_empty()
{
out.push((decoded, ObfuscationTechnique::PsEncodedCommand));
} else if let Some(arg) = extract_sink_argument(&n)
&& let Some(text) = node_extract_text(&arg, source)
&& !text.trim().is_empty()
{
out.push((text.to_string(), ObfuscationTechnique::NestedPsInvocation));
}
}
_ => {}
}
}
}
"sub_expression" => {
if let Some(text) = node_extract_text(&n, source) {
let inner = text
.trim_start_matches("$(")
.trim_end_matches(')')
.to_string();
if !inner.trim().is_empty() {
out.push((inner, ObfuscationTechnique::PsSubExpression));
}
}
}
"invokation_expression" => {
if let Some(decoded) = extract_base64_convert(&n, source) {
out.push((decoded, ObfuscationTechnique::PsBase64Convert));
}
}
_ => {}
}
let mut cursor = n.walk();
for child in n.children(&mut cursor) {
stack.push(child);
}
}
}
fn extract_base64_convert(node: &Node, source: &[u8]) -> Option<String> {
let text = node_extract_text(node, source)?;
let lower = text.to_lowercase();
if !lower.contains("frombase64string") {
return None;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "argument_list" {
let mut ac = child.walk();
for arg in child.children(&mut ac) {
if matches!(arg.kind(), "string_literal" | "expandable_string_literal") {
if let Some(t) = node_extract_text(&arg, source) {
let inner = clean_ps_string(t);
if let Some(decoded) = decode_ps_base64(&inner) {
return Some(decoded);
}
}
}
}
}
}
None
}
fn consume_budget(budget: &AtomicUsize, amount: usize) -> bool {
loop {
let cur = budget.load(Ordering::Relaxed);
if amount > cur {
return false;
}
if budget
.compare_exchange_weak(cur, cur - amount, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
return true;
}
}
}
pub async fn deobfuscate_block(
mut block: crate::security::detect::powershell::ast::CommittedBlock,
ctx: &ShellContext,
ast_state: &PsAstState,
depth: usize,
budget: &AtomicUsize,
) -> Vec<crate::security::detect::powershell::ast::CommittedBlock> {
use crate::security::detect::powershell::ast::CommittedBlock;
if depth > MAX_DEOBF_DEPTH {
tracing::warn!(
target: "security::deobf",
depth,
"max deobfuscation depth exceeded, stop expanding further"
);
return vec![block];
}
let (normalized_src, techs) =
build_normalized_source(&block.tree, block.source.as_bytes(), ctx).await;
if normalized_src != block.source {
match ast_state.reparse(&normalized_src).await {
Some(new_tree) if !new_tree.root_node().has_error() => {
block
.deobf
.raw_source
.get_or_insert_with(|| block.source.clone());
block.source = normalized_src;
block.tree = new_tree;
}
_ => {
tracing::debug!(
target: "security::deobf",
"normalized source failed to reparse cleanly, fallback to original"
);
}
}
}
block.deobf.techniques.extend(techs);
let mut payloads: Vec<(String, ObfuscationTechnique)> = Vec::new();
walk_for_sinks(&block.tree, block.source.as_bytes(), &mut payloads);
let parent_chain = block.deobf.decode_chain.clone();
let mut result = vec![block];
for (payload_text, tech) in payloads {
if !consume_budget(budget, payload_text.len()) {
tracing::warn!(
target: "security::deobf",
payload_len = payload_text.len(),
"deobf byte budget exceeded, dropping remaining payload"
);
continue;
}
let Some(tree) = ast_state.reparse(&payload_text).await else {
continue;
};
if tree.root_node().has_error() {
continue;
}
let mut chain = parent_chain.clone();
chain.push(tech);
let child_block = CommittedBlock {
source: payload_text,
tree,
is_decoded_payload: true,
fragment_count: 0,
deobf: DeobfMeta {
techniques: Vec::new(),
decode_chain: chain,
raw_source: None,
},
};
let expanded = Box::pin(deobfuscate_block(child_block, ctx, ast_state, depth + 1, budget))
.await;
result.extend(expanded);
}
result
}