detect/xor: add inline variable key syntax

Extend the xor transform to accept a variable-position key using
the 'extract <nbytes> <offset>' syntax. The engine reads <nbytes> bytes
at buffer position <offset> at transform time.

The optional 'offset' parameter specifies where XOR decoding
starts in the buffer; bytes before that position are left unchanged.

Syntax:
  xor:"<hex_key>"
  xor:extract <nbytes> <offset>
  xor:offset <N>,"<hex_key>"
  xor:offset <N>,extract <nbytes> <offset>

Variable-key helpers (VariableKeyLocation, variable_key_bytes,
parse_key_location, strip_keyword_prefix) are imported from the new
varkey module.

The variable key bytes are copied out of the inspection buffer before
decoding, so an in-place transform cannot corrupt the key when the key
region overlaps the decoded range.

Issue: 8671
pull/15699/head
Jeff Lucovsky 2 months ago committed by Victor Julien
parent 385bd4416b
commit 5d007ef25e

@ -195,6 +195,9 @@ Takes the buffer, applies xor decoding.
.. note:: this transform requires a mandatory option which is the hexadecimal encoded xor key.
Quotes around a hex key are optional; ``xor:0d0ac8ff`` and ``xor:"0d0ac8ff"``
are equivalent.
This example alerts if ``http.uri`` contains ``password=`` xored with 4-bytes key ``0d0ac8ff``
.. container:: example-rule

@ -1,4 +1,4 @@
/* Copyright (C) 2024 Open Information Security Foundation
/* Copyright (C) 2024-2026 Open Information Security Foundation
*
* You can copy, redistribute or modify this Program under the terms of
* the GNU General Public License version 2 as published by the Free
@ -15,69 +15,196 @@
* 02110-1301, USA.
*/
use crate::detect::SIGMATCH_QUOTES_MANDATORY;
use suricata_sys::sys::{
DetectEngineCtx, DetectEngineThreadCtx, InspectionBuffer, SCDetectHelperTransformRegister,
SCDetectSignatureAddTransform, SCInspectionBufferCheckAndExpand, SCInspectionBufferTruncate,
SCTransformTableElmt, Signature,
SCTransformTableElmt, Signature, SIGMATCH_QUOTES_OPTIONAL,
};
use super::varkey::{
parse_key_location, strip_keyword_prefix, variable_key_bytes, VariableKeyLocation,
};
use std::ffi::CStr;
use std::os::raw::{c_int, c_void};
use std::os::raw::{c_char, c_int, c_void};
static mut G_TRANSFORM_XOR_ID: c_int = 0;
#[derive(Debug, PartialEq)]
/// Where to obtain the XOR key at transform time.
#[derive(Debug)]
enum XorKeySource {
/// A static hex key provided in the rule.
Static(Vec<u8>),
/// Key read directly from the inspection buffer at a fixed location.
Variable(VariableKeyLocation),
}
#[derive(Debug)]
struct DetectTransformXorData {
key: Vec<u8>,
key_source: XorKeySource,
/// Offset in the buffer where XOR decoding starts. Bytes before this
/// offset are copied unchanged. This allows skipping embedded key bytes.
xor_offset: u32,
/// Precomputed identity bytes returned by xor_id. Layout:
/// Static: [0x00, key_bytes..., xor_offset_le4]
/// Variable: [0x01, key_offset_lo, key_offset_hi, nbytes, xor_offset_le4]
/// The leading discriminant byte ensures static and variable identities
/// can never collide. Including xor_offset ensures rules with the same
/// key but different decode-start positions get independent buffers.
id_buf: Vec<u8>,
}
fn xor_parse_do(i: &str) -> Option<DetectTransformXorData> {
if i.len() % 2 != 0 {
SCLogError!("XOR transform key's length must be an even number");
/// Parsed key specifier — either decoded key bytes or an inline buffer location.
#[derive(Debug, PartialEq)]
enum XorKeySpec {
Hex(Vec<u8>),
Var(VariableKeyLocation),
}
/// Intermediate parse result.
#[derive(Debug, PartialEq)]
struct XorParseResult {
key_spec: XorKeySpec,
/// Optional offset where XOR decoding starts.
xor_offset: Option<u32>,
}
/// Try to decode a string as a hex key. Returns `None` if the string is not
/// valid hexadecimal, is empty, or exceeds 255 bytes.
fn try_parse_hex_key(s: &str) -> Option<Vec<u8>> {
hex::decode(s)
.ok()
.filter(|k| !k.is_empty() && k.len() <= usize::from(u8::MAX))
}
/// Parse a key specifier — either `extract <nbytes> <offset>` or a hex string.
fn parse_key_part(s: &str) -> Option<XorKeySpec> {
if let Some(rest) = strip_keyword_prefix(s, "extract") {
if let Some(loc) = parse_key_location(rest) {
return Some(XorKeySpec::Var(loc));
}
SCLogError!("XOR transform: 'extract' requires format 'extract <nbytes> <offset>'");
return None;
}
if i.len() / 2 > usize::from(u8::MAX) {
SCLogError!("Key length too big for XOR transform");
let s = strip_quotes(s);
if s.is_empty() {
SCLogError!("XOR transform: missing hex key");
return None;
}
if let Ok(key) = hex::decode(i) {
return Some(DetectTransformXorData { key });
match try_parse_hex_key(s) {
Some(key) => Some(XorKeySpec::Hex(key)),
None => {
SCLogError!("XOR transform: '{}' is not a valid hex key", s);
None
}
}
SCLogError!("XOR transform key must be hexadecimal characters only");
return None;
}
unsafe fn xor_parse(raw: *const std::os::raw::c_char) -> *mut c_void {
let raw: &CStr = CStr::from_ptr(raw); //unsafe
if let Ok(s) = raw.to_str() {
if let Some(ctx) = xor_parse_do(s) {
let boxed = Box::new(ctx);
return Box::into_raw(boxed) as *mut _;
}
/// Parse the xor option string. Accepts:
/// - `<hex_key>`
/// - `extract <nbytes> <offset>`
/// - `offset <N>,<hex_key>`
/// - `offset <N>,extract <nbytes> <offset>`
fn xor_parse_options(input: &str) -> Option<XorParseResult> {
let input = input.trim();
if input.is_empty() {
SCLogError!("XOR transform: empty argument");
return None;
}
if let Some(rest) = strip_keyword_prefix(input, "offset") {
let (offset_str, key_part) = match rest.split_once(',') {
Some(pair) => pair,
None => {
SCLogError!("XOR transform: 'offset' requires format 'offset <N>,<key>'");
return None;
}
};
let xor_offset: u32 = match offset_str.trim().parse() {
Ok(v) => v,
Err(_) => {
SCLogError!(
"XOR transform: invalid offset value '{}'",
offset_str.trim()
);
return None;
}
};
return Some(XorParseResult {
key_spec: parse_key_part(key_part.trim())?,
xor_offset: Some(xor_offset),
});
}
return std::ptr::null_mut();
Some(XorParseResult {
key_spec: parse_key_part(input)?,
xor_offset: None,
})
}
/// Strip surrounding double quotes from a string if present.
fn strip_quotes(s: &str) -> &str {
s.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(s)
}
fn xor_build_ctx(input: &str) -> Option<DetectTransformXorData> {
let parsed = xor_parse_options(input)?;
let xor_offset = parsed.xor_offset.unwrap_or(0);
let key_source = match parsed.key_spec {
XorKeySpec::Hex(key) => XorKeySource::Static(key),
XorKeySpec::Var(loc) => XorKeySource::Variable(loc),
};
let id_buf = match &key_source {
XorKeySource::Static(key) => {
let mut buf = vec![0x00];
buf.extend_from_slice(key);
buf.extend_from_slice(&xor_offset.to_le_bytes());
buf
}
XorKeySource::Variable(loc) => {
let [lo, hi] = loc.offset.to_le_bytes();
let mut buf = vec![0x01, lo, hi, loc.nbytes];
buf.extend_from_slice(&xor_offset.to_le_bytes());
buf
}
};
Some(DetectTransformXorData {
key_source,
xor_offset,
id_buf,
})
}
unsafe extern "C" fn xor_setup(
de: *mut DetectEngineCtx, s: *mut Signature, opt_str: *const std::os::raw::c_char,
de: *mut DetectEngineCtx, s: *mut Signature, opt_str: *const c_char,
) -> c_int {
let ctx = xor_parse(opt_str);
if ctx.is_null() {
return -1;
}
let input = match CStr::from_ptr(opt_str).to_str() {
Ok(s) => s,
Err(_) => return -1,
};
let ctx = match xor_build_ctx(input) {
Some(d) => Box::into_raw(Box::new(d)) as *mut c_void,
None => return -1,
};
let r = SCDetectSignatureAddTransform(s, G_TRANSFORM_XOR_ID, ctx);
if r != 0 {
xor_free(de, ctx);
}
return r;
r
}
fn xor_transform_do(input: &[u8], output: &mut [u8], ctx: &DetectTransformXorData) {
let mut ki = 0;
for (i, o) in input.iter().zip(output.iter_mut()) {
*o = (*i) ^ ctx.key[ki];
ki = (ki + 1) % ctx.key.len();
/// Apply XOR to `input[xor_offset..]`, copying `input[..xor_offset]` unchanged.
/// The copy is required because the output is a freshly allocated buffer that
/// must contain the complete result at the same length as input.
fn xor_transform_do(input: &[u8], output: &mut [u8], key: &[u8], xor_offset: usize) {
output[..xor_offset].copy_from_slice(&input[..xor_offset]);
for (i, (inp, out)) in input[xor_offset..]
.iter()
.zip(output[xor_offset..].iter_mut())
.enumerate()
{
*out = *inp ^ key[i % key.len()];
}
}
@ -93,19 +220,40 @@ unsafe extern "C" fn xor_transform(
let output = SCInspectionBufferCheckAndExpand(buffer, input_len);
if output.is_null() {
// allocation failure
return;
}
let output = std::slice::from_raw_parts_mut(output, input_len as usize);
let ctx = cast_pointer!(ctx, DetectTransformXorData);
xor_transform_do(input, output, ctx);
let xor_offset = ctx.xor_offset as usize;
if xor_offset > input.len() {
return;
}
// `output` is buffer->buf, which aliases `input` once any prior transform
// has run (SCInspectionBufferTruncate points `inspect` at `buf`). For a
// variable key the key bytes live inside `input`, so an in-place XOR would
// overwrite them mid-pass when the key overlaps the decoded region. Copy
// the key out first; nbytes is at most u8::MAX, so it stays on the stack.
let mut var_key = [0u8; u8::MAX as usize];
let key: &[u8] = match &ctx.key_source {
XorKeySource::Static(key) => key,
XorKeySource::Variable(location) => match variable_key_bytes(input, location) {
Some(k) => {
var_key[..k.len()].copy_from_slice(k);
&var_key[..k.len()]
}
None => return,
},
};
xor_transform_do(input, output, key, xor_offset);
SCInspectionBufferTruncate(buffer, input_len);
}
unsafe extern "C" fn xor_free(_de: *mut DetectEngineCtx, ctx: *mut c_void) {
std::mem::drop(Box::from_raw(ctx as *mut DetectTransformXorData));
drop(Box::from_raw(ctx as *mut DetectTransformXorData));
}
unsafe extern "C" fn xor_id(data: *mut *const u8, length: *mut u32, ctx: *const c_void) {
@ -114,8 +262,8 @@ unsafe extern "C" fn xor_id(data: *mut *const u8, length: *mut u32, ctx: *const
}
let ctx = cast_pointer!(ctx, DetectTransformXorData);
*data = ctx.key.as_ptr();
*length = ctx.key.len() as u32;
*data = ctx.id_buf.as_ptr();
*length = ctx.id_buf.len() as u32;
}
#[no_mangle]
@ -125,7 +273,7 @@ pub unsafe extern "C" fn DetectTransformXorRegister() {
desc: b"modify buffer via XOR decoding before inspection\0".as_ptr() as *const libc::c_char,
url: b"/rules/transforms.html#xor\0".as_ptr() as *const libc::c_char,
Setup: Some(xor_setup),
flags: SIGMATCH_QUOTES_MANDATORY,
flags: SIGMATCH_QUOTES_OPTIONAL,
Transform: Some(xor_transform),
Free: Some(xor_free),
TransformValidate: None,
@ -144,52 +292,304 @@ mod tests {
use super::*;
#[test]
fn test_xor_parse() {
assert!(xor_parse_do("nohexa").is_none());
let key = b"\x0a\x0d\xc8\xff";
fn test_parse_hex_key() {
let r = xor_parse_options("0a0DC8ff").unwrap();
assert_eq!(r.key_spec, XorKeySpec::Hex(vec![0x0a, 0x0d, 0xc8, 0xff]));
assert_eq!(r.xor_offset, None);
assert_eq!(
xor_parse_do("0a0DC8ff"),
Some(DetectTransformXorData { key: key.to_vec() })
try_parse_hex_key("0a0DC8ff"),
Some(vec![0x0a, 0x0d, 0xc8, 0xff])
);
}
#[test]
fn test_xor_id() {
fn test_parse_variable() {
let r = xor_parse_options("extract 1 0").unwrap();
assert_eq!(
r.key_spec,
XorKeySpec::Var(VariableKeyLocation {
nbytes: 1,
offset: 0
})
);
assert_eq!(r.xor_offset, None);
}
#[test]
fn test_parse_variable_large_offset() {
let r = xor_parse_options("extract 4 1024").unwrap();
assert_eq!(
r.key_spec,
XorKeySpec::Var(VariableKeyLocation {
nbytes: 4,
offset: 1024
})
);
}
#[test]
fn test_parse_offset_variable() {
let r = xor_parse_options("offset 1,extract 1 0").unwrap();
assert_eq!(
r.key_spec,
XorKeySpec::Var(VariableKeyLocation {
nbytes: 1,
offset: 0
})
);
assert_eq!(r.xor_offset, Some(1));
}
#[test]
fn test_parse_offset_hex() {
let r = xor_parse_options("offset 4,0d0ac8ff").unwrap();
assert_eq!(r.key_spec, XorKeySpec::Hex(vec![0x0d, 0x0a, 0xc8, 0xff]));
assert_eq!(r.xor_offset, Some(4));
}
#[test]
fn test_parse_empty() {
assert!(xor_parse_options("").is_none());
}
#[test]
fn test_parse_offset_missing_key() {
assert!(xor_parse_options("offset 1,").is_none());
}
#[test]
fn test_parse_offset_no_comma() {
assert!(xor_parse_options("offset 1").is_none());
}
#[test]
fn test_parse_empty_hex_key() {
assert!(try_parse_hex_key("").is_none());
}
#[test]
fn test_parse_hex_key_odd_length() {
assert!(try_parse_hex_key("abc").is_none());
}
#[test]
fn test_parse_variable_missing_offset() {
assert!(xor_parse_options("extract 1").is_none());
}
#[test]
fn test_parse_variable_nbytes_overflow() {
// 256 does not fit in u8.
assert!(xor_parse_options("extract 256 0").is_none());
}
#[test]
fn test_xor_transform_no_offset() {
let input = b"example.com";
let mut out = vec![0u8; input.len()];
let key = hex::decode("0a0DC8ff").unwrap();
xor_transform_do(input, &mut out, &key, 0);
assert_eq!(out, b"ou\xa9\x92za\xad\xd1ib\xa5");
}
#[test]
fn test_xor_transform_with_offset() {
let key_byte = 0x42u8;
let plaintext = b"hello";
let mut body = vec![key_byte];
for &b in plaintext {
body.push(b ^ key_byte);
}
let mut out = vec![0u8; body.len()];
xor_transform_do(&body, &mut out, &[key_byte], 1);
assert_eq!(out[0], key_byte);
assert_eq!(&out[1..], plaintext);
}
#[test]
fn test_xor_transform_roundtrip() {
// XOR is its own inverse: applying the same key twice recovers the original.
let input = b"example.com";
let key = hex::decode("0a0DC8ff").unwrap();
let mut encrypted = vec![0u8; input.len()];
xor_transform_do(input, &mut encrypted, &key, 0);
let mut recovered = vec![0u8; encrypted.len()];
xor_transform_do(&encrypted, &mut recovered, &key, 0);
assert_eq!(recovered, input);
}
// Build an id_buf for a variable key the same way xor_build_ctx does.
fn make_id_buf_var(key_offset: u16, nbytes: u8, xor_offset: u32) -> Vec<u8> {
let [lo, hi] = key_offset.to_le_bytes();
let mut buf = vec![0x01, lo, hi, nbytes];
buf.extend_from_slice(&xor_offset.to_le_bytes());
buf
}
// Build an id_buf for a static key the same way xor_build_ctx does.
fn make_id_buf_static(key: &[u8], xor_offset: u32) -> Vec<u8> {
let mut buf = vec![0x00];
buf.extend_from_slice(key);
buf.extend_from_slice(&xor_offset.to_le_bytes());
buf
}
#[test]
fn test_xor_id_variable_key() {
// key_offset=0, nbytes=1, xor_offset=0 → [0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]
let id_buf = make_id_buf_var(0, 1, 0);
let ctx = Box::new(DetectTransformXorData {
key: vec![1, 2, 3, 4, 5],
key_source: XorKeySource::Variable(VariableKeyLocation {
offset: 0,
nbytes: 1,
}),
xor_offset: 0,
id_buf,
});
let ctx_ptr: *const c_void = &*ctx as *const _ as *const c_void;
let mut data_ptr: *const u8 = std::ptr::null();
let mut length: u32 = 0;
unsafe {
xor_id(&mut data_ptr, &mut length, ctx_ptr as *mut c_void);
assert!(!data_ptr.is_null());
assert_eq!(length, 8);
assert_eq!(
std::slice::from_raw_parts(data_ptr, 8),
&[0x01u8, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]
);
}
}
#[test]
fn test_xor_id_variable_key_large_offset() {
// key_offset=300 (0x012c LE = [0x2c, 0x01]), nbytes=4, xor_offset=0
let id_buf = make_id_buf_var(300, 4, 0);
let ctx = Box::new(DetectTransformXorData {
key_source: XorKeySource::Variable(VariableKeyLocation {
offset: 300,
nbytes: 4,
}),
xor_offset: 0,
id_buf,
});
let ctx_ptr: *const c_void = &*ctx as *const _ as *const c_void;
let mut data_ptr: *const u8 = std::ptr::null();
let mut length: u32 = 0;
unsafe {
xor_id(
&mut data_ptr as *mut *const u8,
&mut length as *mut u32,
ctx_ptr as *mut c_void,
xor_id(&mut data_ptr, &mut length, ctx_ptr as *mut c_void);
assert!(!data_ptr.is_null());
assert_eq!(length, 8);
assert_eq!(
std::slice::from_raw_parts(data_ptr, 8),
&[0x01u8, 0x2c, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00]
);
}
}
#[test]
fn test_xor_id_variable_key_nonzero_xor_offset() {
// Verify that two rules with the same key location but different xor_offset
// produce distinct id_bufs and therefore get independent buffers.
let id_a = make_id_buf_var(0, 1, 0);
let id_b = make_id_buf_var(0, 1, 5);
assert_ne!(id_a, id_b);
}
#[test]
fn test_var_key_transform() {
// Buffer: [key_byte, encoded[0], encoded[1], ...]
// Key is 1 byte at offset 0; XOR starts at offset 1.
let key_byte = 0x42u8;
let plaintext = b"world";
let mut buf = vec![key_byte];
buf.extend(plaintext.iter().map(|&b| b ^ key_byte));
let loc = VariableKeyLocation {
offset: 0,
nbytes: 1,
};
let key = variable_key_bytes(&buf, &loc).expect("key should be in bounds");
let mut out = vec![0u8; buf.len()];
xor_transform_do(&buf, &mut out, key, 1);
assert_eq!(out[0], key_byte);
assert_eq!(&out[1..], plaintext.as_ref());
}
#[test]
fn test_xor_id() {
// Static key [1,2,3,4,5] with xor_offset=0: id = [0x00, key_bytes..., 0,0,0,0]
let key = vec![1u8, 2, 3, 4, 5];
let id_buf = make_id_buf_static(&key, 0);
let ctx = Box::new(DetectTransformXorData {
key_source: XorKeySource::Static(key),
xor_offset: 0,
id_buf,
});
assert!(!data_ptr.is_null(), "data_ptr should not be null");
assert_eq!(length, 5);
let ctx_ptr: *const c_void = &*ctx as *const _ as *const c_void;
let mut data_ptr: *const u8 = std::ptr::null();
let mut length: u32 = 0;
unsafe {
xor_id(&mut data_ptr, &mut length, ctx_ptr as *mut c_void);
assert!(!data_ptr.is_null());
assert_eq!(length, 10);
let actual = std::slice::from_raw_parts(data_ptr, length as usize);
assert_eq!(actual, &[1, 2, 3, 4, 5]);
assert_eq!(actual, &[0, 1, 2, 3, 4, 5, 0, 0, 0, 0]);
}
}
#[test]
fn test_xor_transform() {
let mut buf = Vec::new();
buf.extend_from_slice(b"example.com");
let mut out = vec![0; buf.len()];
let ctx = xor_parse_do("0a0DC8ff").unwrap();
xor_transform_do(&buf, &mut out, &ctx);
assert_eq!(out, b"ou\xa9\x92za\xad\xd1ib\xa5");
// test in place
let still_buf = unsafe { std::slice::from_raw_parts(buf.as_ptr(), buf.len()) };
xor_transform_do(still_buf, &mut buf, &ctx);
assert_eq!(&still_buf, b"ou\xa9\x92za\xad\xd1ib\xa5");
fn test_xor_id_static_key_nonzero_xor_offset() {
// Two rules with the same static key but different xor_offset must
// produce distinct identities.
let key = vec![0x42u8];
let id_a = make_id_buf_static(&key, 0);
let id_b = make_id_buf_static(&key, 1);
assert_ne!(id_a, id_b);
}
#[test]
fn test_xor_id_static_vs_variable_no_collision() {
// A 3-byte static key whose bytes happen to equal [offset_lo, offset_hi, nbytes]
// of a variable key must still produce a distinct identity.
let key = vec![0x00u8, 0x00, 0x01];
let static_id = make_id_buf_static(&key, 0);
let var_id = make_id_buf_var(0, 1, 0);
assert_ne!(static_id, var_id);
}
#[test]
fn test_var_key_inplace_overlap() {
// Reproduces the in-place case: the key (offset 0, 1 byte) lies inside
// the decoded region (xor_offset 0). The key must be copied before
// decoding -- otherwise writing out[0] corrupts the key for every
// subsequent byte and the plaintext is never recovered.
let key_byte = 0x42u8;
let plaintext = b"infected";
let mut buf = vec![key_byte];
buf.extend(plaintext.iter().map(|&b| b ^ key_byte));
let loc = VariableKeyLocation {
offset: 0,
nbytes: 1,
};
let key = variable_key_bytes(&buf, &loc).unwrap().to_vec(); // copy, as the fix does
for i in 0..buf.len() {
buf[i] ^= key[i % key.len()]; // XOR in place over the whole buffer
}
assert_eq!(buf[0], 0x00);
assert_eq!(&buf[1..], plaintext);
}
#[test]
fn test_parse_offset_hex_quoted() {
// The inner hex key in "offset N,..." may be quoted; strip_quotes must handle it.
let r = xor_parse_options("offset 4,\"0d0ac8ff\"").unwrap();
assert_eq!(r.key_spec, XorKeySpec::Hex(vec![0x0d, 0x0a, 0xc8, 0xff]));
assert_eq!(r.xor_offset, Some(4));
}
}

Loading…
Cancel
Save