1use adhammer_core::sid::{Guid, Sid};
19use adhammer_core::snapshot::Snapshot;
20use adhammer_core::AdObject;
21use serde_json::{json, Value};
22use std::collections::HashMap;
23use windows_sddl::rights;
24use windows_sddl::{AccessMask, AceType};
25
26#[cfg(feature = "rusthound-ce")]
28pub mod rusthound_ce;
29
30const VERSION: u32 = 5;
31
32#[derive(Clone, Copy, PartialEq)]
34enum Kind {
35 User,
36 Computer,
37 Group,
38 Domain,
39 Ou,
40 Gpo,
41 Container,
42 Base,
43}
44
45impl Kind {
46 fn as_str(self) -> &'static str {
47 match self {
48 Kind::User => "User",
49 Kind::Computer => "Computer",
50 Kind::Group => "Group",
51 Kind::Domain => "Domain",
52 Kind::Ou => "OU",
53 Kind::Gpo => "GPO",
54 Kind::Container => "Container",
55 Kind::Base => "Base",
56 }
57 }
58}
59
60fn kind_of(o: &AdObject) -> Kind {
61 if o.has_class("computer") {
62 Kind::Computer
63 } else if o.has_class("group") {
64 Kind::Group
65 } else if o.has_class("domainDNS") {
66 Kind::Domain
67 } else if o.has_class("groupPolicyContainer") {
68 Kind::Gpo
69 } else if o.has_class("organizationalUnit") {
70 Kind::Ou
71 } else if o.has_class("user") {
72 Kind::User
73 } else if o.has_class("container") {
74 Kind::Container
75 } else {
76 Kind::Base
77 }
78}
79
80fn dn_to_fqdn(dn: &str) -> String {
82 dn.split(',')
83 .filter_map(|p| p.trim().strip_prefix("DC="))
84 .collect::<Vec<_>>()
85 .join(".")
86}
87
88fn guid_upper(o: &AdObject) -> Option<String> {
89 o.bin1("objectGUID")
90 .and_then(Guid::from_bytes)
91 .map(|g| g.to_string().to_uppercase())
92}
93
94fn object_id(o: &AdObject, kind: Kind) -> Option<String> {
96 match kind {
97 Kind::User | Kind::Computer | Kind::Group | Kind::Domain => o
98 .bin1("objectSid")
99 .and_then(Sid::from_bytes)
100 .map(|s| s.to_string().to_uppercase()),
101 _ => guid_upper(o),
102 }
103}
104
105struct Ctx {
106 domain_fqdn_upper: String,
107 domain_sid: String,
108 type_by_sid: HashMap<String, Kind>,
110 id_by_dn: HashMap<String, (String, Kind)>,
112}
113
114pub fn export_files(snap: &Snapshot) -> Vec<(String, Vec<u8>)> {
116 let fqdn = dn_to_fqdn(&snap.domain.domain_dn);
117 let domain_sid = snap
118 .domain
119 .domain_sid
120 .as_ref()
121 .map(|s| s.to_string().to_uppercase())
122 .unwrap_or_default();
123
124 let mut ctx = Ctx {
125 domain_fqdn_upper: fqdn.to_uppercase(),
126 domain_sid,
127 type_by_sid: HashMap::new(),
128 id_by_dn: HashMap::new(),
129 };
130 for o in &snap.objects {
131 let k = kind_of(o);
132 if let Some(sid) = o.bin1("objectSid").and_then(Sid::from_bytes) {
133 ctx.type_by_sid.insert(sid.to_string().to_uppercase(), k);
134 }
135 if let Some(id) = object_id(o, k) {
136 ctx.id_by_dn.insert(o.dn.to_uppercase(), (id, k));
137 }
138 }
139
140 let mut buckets: HashMap<&'static str, Vec<Value>> = HashMap::new();
141 for name in [
142 "users",
143 "computers",
144 "groups",
145 "domains",
146 "ous",
147 "gpos",
148 "containers",
149 ] {
150 buckets.insert(name, Vec::new());
151 }
152
153 for o in &snap.objects {
154 let kind = kind_of(o);
155 let Some(id) = object_id(o, kind) else {
156 continue;
157 };
158 let node = build_node(&ctx, o, kind, &id);
159 let bucket = match kind {
160 Kind::User => "users",
161 Kind::Computer => "computers",
162 Kind::Group => "groups",
163 Kind::Domain => "domains",
164 Kind::Ou => "ous",
165 Kind::Gpo => "gpos",
166 Kind::Container | Kind::Base => "containers",
167 };
168 buckets.get_mut(bucket).unwrap().push(node);
169 }
170
171 buckets
172 .into_iter()
173 .map(|(typ, data)| {
174 let doc = json!({
175 "data": data,
176 "meta": { "methods": 0, "type": typ, "count": data.len(), "version": VERSION }
177 });
178 (
179 format!("{}_{typ}.json", ctx.domain_fqdn_upper.to_lowercase()),
180 serde_json::to_vec(&doc).unwrap(),
181 )
182 })
183 .collect()
184}
185
186pub fn export_zip(snap: &Snapshot, path: &std::path::Path) -> std::io::Result<usize> {
188 let files = export_files(snap);
189 let n = files.len();
190 let zip = zip_store(&files);
191 std::fs::write(path, zip)?;
192 Ok(n)
193}
194
195fn build_node(ctx: &Ctx, o: &AdObject, kind: Kind, id: &str) -> Value {
196 let mut props = base_properties(ctx, o, kind);
197 let aces = build_aces(ctx, o);
198 let owner_ace = owner_ace(ctx, o);
199
200 let mut node = json!({
201 "ObjectIdentifier": id,
202 "Aces": [],
203 "IsDeleted": false,
204 "IsACLProtected": false,
205 });
206 let mut ace_list = Vec::new();
208 ace_list.extend(owner_ace);
209 ace_list.extend(aces);
210 node["Aces"] = Value::Array(ace_list);
211
212 match kind {
213 Kind::User => {
214 props["hasspn"] = json!(!o.all("servicePrincipalName").is_empty());
215 node["Properties"] = props;
216 node["PrimaryGroupSID"] = json!(primary_group_sid(ctx, o));
217 node["HasSIDHistory"] = json!(sid_history(o));
218 node["SPNTargets"] = json!([]);
219 node["AllowedToDelegate"] = json!([]);
220 }
221 Kind::Computer => {
222 node["Properties"] = props;
223 node["PrimaryGroupSID"] = json!(primary_group_sid(ctx, o));
224 node["HasSIDHistory"] = json!(sid_history(o));
225 node["AllowedToDelegate"] = json!([]);
226 node["AllowedToAct"] = json!([]);
227 node["DumpSMSAPassword"] = json!([]);
228 for coll in ["Sessions", "PrivilegedSessions", "RegistrySessions"] {
229 node[coll] = json!({"Results": [], "Collected": false, "FailureReason": null});
230 }
231 for coll in [
232 "LocalAdmins",
233 "RemoteDesktopUsers",
234 "DcomUsers",
235 "PSRemoteUsers",
236 ] {
237 node[coll] = json!({"Results": [], "Collected": false, "FailureReason": null});
238 }
239 node["Status"] = Value::Null;
240 }
241 Kind::Group => {
242 node["Properties"] = props;
243 node["Members"] = json!(group_members(ctx, o));
244 }
245 Kind::Domain => {
246 node["Properties"] = props;
247 node["Trusts"] = json!([]);
248 node["Links"] = json!([]);
249 node["ChildObjects"] = json!([]);
250 node["GPOChanges"] = json!({
251 "LocalAdmins": [], "RemoteDesktopUsers": [], "DcomUsers": [],
252 "PSRemoteUsers": [], "AffectedComputers": []
253 });
254 }
255 Kind::Ou => {
256 node["Properties"] = props;
257 node["Links"] = json!([]);
258 node["ChildObjects"] = json!([]);
259 node["GPOChanges"] = json!({
260 "LocalAdmins": [], "RemoteDesktopUsers": [], "DcomUsers": [],
261 "PSRemoteUsers": [], "AffectedComputers": []
262 });
263 }
264 Kind::Gpo => {
265 node["Properties"] = props;
266 }
267 Kind::Container | Kind::Base => {
268 node["Properties"] = props;
269 node["ChildObjects"] = json!([]);
270 }
271 }
272 node
273}
274
275fn base_properties(ctx: &Ctx, o: &AdObject, kind: Kind) -> Value {
276 let dn = o.dn.to_uppercase();
277 let name = display_name(ctx, o, kind);
278 let mut p = json!({
279 "domain": ctx.domain_fqdn_upper,
280 "domainsid": ctx.domain_sid,
281 "name": name,
282 "distinguishedname": dn,
283 "whencreated": o.int("whenCreated").unwrap_or(0),
284 });
285 if let Some(desc) = o.one("description") {
286 p["description"] = json!(desc);
287 }
288 let admincount = o.int("adminCount").unwrap_or(0) != 0;
289 p["admincount"] = json!(admincount);
290 p["highvalue"] = json!(is_high_value(ctx, o, kind));
291
292 if matches!(kind, Kind::User | Kind::Computer) {
293 let uac = o.uac();
294 p["enabled"] = json!(uac & adhammer_core::object::uac::ACCOUNTDISABLE == 0);
295 p["unconstraineddelegation"] =
296 json!(uac & adhammer_core::object::uac::TRUSTED_FOR_DELEGATION != 0);
297 p["trustedtoauth"] =
298 json!(uac & adhammer_core::object::uac::TRUSTED_TO_AUTH_FOR_DELEGATION != 0);
299 p["pwdlastset"] = json!(filetime_to_unix(o.filetime("pwdLastSet")));
300 p["lastlogontimestamp"] = json!(filetime_to_unix(o.filetime("lastLogonTimestamp")));
301 p["samaccountname"] = json!(o.one("sAMAccountName").unwrap_or_default());
302 if let Some(spns) = o.attrs.get("servicePrincipalName") {
303 p["serviceprincipalnames"] = json!(spns);
304 }
305 }
306 if kind == Kind::User {
307 let uac = o.uac();
308 p["dontreqpreauth"] = json!(uac & adhammer_core::object::uac::DONT_REQ_PREAUTH != 0);
309 p["passwordnotreqd"] = json!(uac & adhammer_core::object::uac::PASSWD_NOTREQD != 0);
310 p["sensitive"] = json!(uac & adhammer_core::object::uac::NOT_DELEGATED != 0);
311 if let Some(upn) = o.one("userPrincipalName") {
312 p["email"] = json!(upn);
313 }
314 }
315 if kind == Kind::Computer {
316 p["operatingsystem"] = json!(o.one("operatingSystem").unwrap_or_default());
317 }
318 p
319}
320
321fn display_name(ctx: &Ctx, o: &AdObject, kind: Kind) -> String {
324 let dom = &ctx.domain_fqdn_upper;
325 match kind {
326 Kind::Domain => dom.clone(),
327 Kind::Computer => {
328 let host = o
329 .one("dNSHostName")
330 .map(str::to_string)
331 .or_else(|| {
332 o.one("sAMAccountName")
333 .map(|s| s.trim_end_matches('$').to_string())
334 })
335 .unwrap_or_default()
336 .to_uppercase();
337 if host.contains('.') {
338 host
339 } else {
340 format!("{host}.{dom}")
341 }
342 }
343 _ => {
344 let sam = o
345 .one("sAMAccountName")
346 .map(str::to_string)
347 .or_else(|| o.one("cn").map(str::to_string))
348 .or_else(|| o.one("name").map(str::to_string))
349 .unwrap_or_default()
350 .to_uppercase();
351 format!("{sam}@{dom}")
352 }
353 }
354}
355
356fn is_high_value(ctx: &Ctx, o: &AdObject, kind: Kind) -> bool {
357 if kind == Kind::Group {
359 if let Some(sid) = o.bin1("objectSid").and_then(Sid::from_bytes) {
360 if let Some(rid) = sid.rid() {
361 if matches!(rid, 512 | 516 | 518 | 519 | 520)
362 && sid.to_string().to_uppercase().starts_with(&ctx.domain_sid)
363 {
364 return true;
365 }
366 }
367 if sid.to_string() == "S-1-5-32-544" {
369 return true;
370 }
371 }
372 }
373 false
374}
375
376fn primary_group_sid(ctx: &Ctx, o: &AdObject) -> Option<String> {
377 o.int("primaryGroupID")
378 .map(|rid| format!("{}-{}", ctx.domain_sid, rid))
379}
380
381fn sid_history(o: &AdObject) -> Vec<Value> {
382 o.bin_all("sIDHistory")
383 .iter()
384 .filter_map(|b| Sid::from_bytes(b))
385 .map(|s| {
386 let id = s.to_string().to_uppercase();
387 json!({"ObjectIdentifier": id, "ObjectType": "User"})
388 })
389 .collect()
390}
391
392fn group_members(ctx: &Ctx, o: &AdObject) -> Vec<Value> {
393 o.all("member")
394 .iter()
395 .filter_map(|dn| ctx.id_by_dn.get(&dn.to_uppercase()))
396 .map(|(id, k)| json!({"ObjectIdentifier": id, "ObjectType": k.as_str()}))
397 .collect()
398}
399
400fn principal_type(ctx: &Ctx, sid: &Sid) -> &'static str {
401 ctx.type_by_sid
402 .get(&sid.to_string().to_uppercase())
403 .copied()
404 .unwrap_or(Kind::Base)
405 .as_str()
406}
407
408fn owner_ace(ctx: &Ctx, o: &AdObject) -> Vec<Value> {
410 let Some(raw) = o.bin1("nTSecurityDescriptor") else {
411 return vec![];
412 };
413 let Ok(sd) = windows_sddl::parse(raw) else {
414 return vec![];
415 };
416 let Some(owner) = sd.owner else { return vec![] };
417 if owner.is_well_known() {
418 return vec![];
419 }
420 vec![ace(
421 &owner.to_string().to_uppercase(),
422 principal_type(ctx, &owner),
423 "Owns",
424 false,
425 )]
426}
427
428fn build_aces(ctx: &Ctx, o: &AdObject) -> Vec<Value> {
430 let Some(raw) = o.bin1("nTSecurityDescriptor") else {
431 return vec![];
432 };
433 let Ok(sd) = windows_sddl::parse(raw) else {
434 return vec![];
435 };
436 if sd.dacl_kind != windows_sddl::DaclKind::Present {
437 return vec![ace("S-1-1-0", "Group", "GenericAll", false)];
438 }
439 let mut out = Vec::new();
440 for a in sd.dacl.iter().flat_map(|d| &d.aces) {
441 if a.ace_type == AceType::AccessDenied || a.ace_type == AceType::AccessDeniedObject {
442 continue;
443 }
444 if a.trustee.is_well_known() {
445 continue;
446 }
447 let inherited = a.flags & 0x10 != 0; let sid = a.trustee.to_string().to_uppercase();
449 let ptype = principal_type(ctx, &a.trustee);
450 for right in ace_rights(a) {
451 out.push(ace(&sid, ptype, right, inherited));
452 }
453 }
454 out
455}
456
457fn ace(principal: &str, ptype: &str, right: &str, inherited: bool) -> Value {
458 json!({
459 "PrincipalSID": principal,
460 "PrincipalType": ptype,
461 "RightName": right,
462 "IsInherited": inherited,
463 })
464}
465
466fn ace_rights(a: &windows_sddl::Ace) -> Vec<&'static str> {
468 let m = a.mask;
469 let mut v = Vec::new();
470 if m.contains(AccessMask::GENERIC_ALL) {
471 return vec!["GenericAll"];
472 }
473 if m.contains(AccessMask::WRITE_DAC) {
474 v.push("WriteDacl");
475 }
476 if m.contains(AccessMask::WRITE_OWNER) {
477 v.push("WriteOwner");
478 }
479 if m.contains(AccessMask::GENERIC_WRITE) {
480 v.push("GenericWrite");
481 }
482 if m.contains(AccessMask::CONTROL_ACCESS) {
483 match &a.object_type {
484 None => v.push("AllExtendedRights"),
485 Some(g) if rights::FORCE_CHANGE_PASSWORD.matches(g) => v.push("ForceChangePassword"),
486 Some(g) if rights::REPL_GET_CHANGES.matches(g) => v.push("GetChanges"),
487 Some(g) if rights::REPL_GET_CHANGES_ALL.matches(g) => v.push("GetChangesAll"),
488 Some(_) => {}
489 }
490 }
491 if m.contains(AccessMask::WRITE_PROP) {
492 match &a.object_type {
493 None => {
494 if !v.contains(&"GenericWrite") {
495 v.push("GenericWrite");
496 }
497 }
498 Some(g) if rights::MEMBER_ATTR.matches(g) => v.push("AddMember"),
499 Some(g) if rights::KEY_CREDENTIAL_LINK.matches(g) => v.push("AddKeyCredentialLink"),
500 Some(g) if rights::RBCD_ATTR.matches(g) => v.push("AddAllowedToAct"),
501 Some(_) => {}
502 }
503 }
504 v
505}
506
507fn filetime_to_unix(ft: Option<i64>) -> i64 {
509 match ft {
510 Some(t) if t > 0 => t / 10_000_000 - 11_644_473_600,
511 _ => -1,
512 }
513}
514
515fn zip_store(files: &[(String, Vec<u8>)]) -> Vec<u8> {
519 let mut out = Vec::new();
520 let mut central = Vec::new();
521 let mut offsets = Vec::new();
522
523 for (name, data) in files {
524 let crc = crc32(data);
525 let off = out.len() as u32;
526 offsets.push(off);
527 let name_b = name.as_bytes();
528 out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
530 out.extend_from_slice(&20u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&crc.to_le_bytes());
536 out.extend_from_slice(&(data.len() as u32).to_le_bytes()); out.extend_from_slice(&(data.len() as u32).to_le_bytes()); out.extend_from_slice(&(name_b.len() as u16).to_le_bytes());
539 out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name_b);
541 out.extend_from_slice(data);
542 }
543
544 for ((name, data), off) in files.iter().zip(offsets) {
545 let crc = crc32(data);
546 let name_b = name.as_bytes();
547 central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
548 central.extend_from_slice(&20u16.to_le_bytes()); central.extend_from_slice(&20u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&crc.to_le_bytes());
555 central.extend_from_slice(&(data.len() as u32).to_le_bytes());
556 central.extend_from_slice(&(data.len() as u32).to_le_bytes());
557 central.extend_from_slice(&(name_b.len() as u16).to_le_bytes());
558 central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u32.to_le_bytes()); central.extend_from_slice(&off.to_le_bytes());
564 central.extend_from_slice(name_b);
565 }
566
567 let central_off = out.len() as u32;
568 let central_size = central.len() as u32;
569 out.extend_from_slice(¢ral);
570 out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
572 out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&(files.len() as u16).to_le_bytes());
575 out.extend_from_slice(&(files.len() as u16).to_le_bytes());
576 out.extend_from_slice(¢ral_size.to_le_bytes());
577 out.extend_from_slice(¢ral_off.to_le_bytes());
578 out.extend_from_slice(&0u16.to_le_bytes()); out
580}
581
582fn crc32(data: &[u8]) -> u32 {
583 let mut crc = 0xFFFF_FFFFu32;
584 for &b in data {
585 crc ^= b as u32;
586 for _ in 0..8 {
587 let mask = (crc & 1).wrapping_neg();
588 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
589 }
590 }
591 !crc
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn crc32_matches_known_vector() {
600 assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
602 }
603
604 #[test]
605 fn zip_has_signatures() {
606 let z = zip_store(&[("a.json".into(), b"{}".to_vec())]);
607 assert_eq!(&z[0..4], &0x0403_4b50u32.to_le_bytes()); assert!(z.windows(4).any(|w| w == 0x0605_4b50u32.to_le_bytes()));
610 }
611
612 #[test]
613 fn fqdn_from_dn() {
614 assert_eq!(dn_to_fqdn("DC=corp,DC=local"), "corp.local");
615 }
616
617 #[test]
618 fn filetime_conversion() {
619 assert_eq!(filetime_to_unix(None), -1);
620 assert_eq!(filetime_to_unix(Some(0)), -1);
621 assert_eq!(
623 filetime_to_unix(Some(132_539_328_000_000_000)),
624 1_609_459_200
625 );
626 }
627}