Peeling the Sentinel: A Market-Leading EDR Comes Apart With Undergraduate Tools
A reverse-engineering teardown of SentinelOne Agent 26.1.2.177. The alarming part isn’t any single detection rule it’s how little skill, and how little time, it took to pull the whole detection stack apart on a workbench.
A note on the code in this post. The snippets are redacted excerpts from the analysis tooling. Secret keys are masked and local paths are placeholders. The mechanisms are shown deliberately, because the mechanism is the finding: none of this required breaking real cryptography, and none of it required an exploit. This is a companion to a longer architectural write-up of the same agent; where that report stays neutral, this one is meant to ring a bell.
The thesis, up front§
EDR vendors expect their customers to think about their agents as of hard controls residing in the most privileged environment. As part of the promise made to the customer, there is an expectation of security in terms of confidentiality: an attacker should not be able to get inside the head of the agent and figure out how the agent works and which decisions are taken on the ground.
That is far from being the case for SentinelOne Agent 26.1.2.177 on Windows. All of the local detection policy corpus, the rules, the machine learning models, the trust/allow-lists were extracted and read in plaintext offline using Ghidra and a few dozens lines of Python code. None of the cryptography was broken. There were no zero-days o strange behaviours by the agent. Techniques applied are standard for introductory reverse-engineering/cryptography class.
This is the threat. Not that all EDR can be reverse-engineered (they do), but that this particular one gives away the information to a beginner analyst in an afternoon.
All the conclusions stated below are based on the analysis of the extracted files only. When a certain fact is inferred and not proven, that is stated explicitly.
How little this took§
Prior to the discovery, the entry barrier, which is the point:
- Environment: one agent running on disk, files from
C:\Program Files\SentinelOne\andC:\ProgramData\Sentinel\. Offline only. - Tools: Ghidra on the binaries, stock Python (
struct,base64, plus one standard RC4 loop) on all formats. - Cryptography: none. The configuration layer is XOR against a key repeated periodically; the key is retrieved using textbook known-plaintext attacks. The rules are RC4 encrypted with a key that is embedded into the product itself. The compiled YARA is XOR-
0xFF. - Exploitation: none. No bypasses were discovered at run-time; the files are just decrypted.
- Source code: each decryptor/parser listed below is about 10–60 lines.
If PoC fits into a tweet, "hard to inspect" is not an actual feature of the product.
The architecture in one breath§
To be fair to the design, the detection architecture is genuinely layered. Confirmed subsystems:
| Subsystem | What it is | Confirmed scale |
|---|---|---|
| Static AI Engine | Pre-execution file classifier (SentinelStaticAI.dll, a generic YARA + Tree-sitter engine) | ~2,500 rules across 26 file-type categories |
| Behavioral ML Model | Runtime process classifier (primaryBehavioralModel.bin) | 65-condition decision chain, 5 features used |
| Discovery AI Model | Second model (discovery_ai.json) | 9,289-tree DAG forest, 162 thresholds |
| Lua Behavioral Rules | Scripted detections on telemetry (Win-LUA-Behavioral.tar) | 307+ rules, decrypted to source |
| Lunar Engine | Behavioral correlation framework (LunarEngineWin.tar) | 764 rule definitions, 202 behavioral indicators |
| Driver Rules | Kernel driver blocklist (DriverRules.json) | 1.4 MB, decrypted |
| Memory Scanner | In-memory YARA sets | Per-file-type rules |
Beneath these sit kernel drivers (SentinelMonitor.sys, SentinelNetworkMonitor.sys, SentinelDeviceControl.sys, boot-start SentinelELAM.sys) and user-mode injection/hooking (InProcessClient*.dll, Shadow32/64.dll, AMSI and .NET/Java instrumentation). The ambition is not the problem. The problem is that the protective shell around all of this local content is ornamental.
Finding #1 The “encryption” is obfuscation, not protection§
Config and policy: one static XOR key for everything§
Each and every sensitive document that the agent keeps including Policy.json, DriverRules.json, StaticRuleWin.json, SpecialImageAdjustments.json, both the Lua tarballs, and so on has a magic 0B F5 CA ED followed by 4-byte category. The rest of the file is XORed with a 11-byte key which is common to all the files. As proven by decrypting all of them with one key:
# decrypt_all.py
import struct
# 11-byte static XOR key, identical for every encrypted file. Value redacted.
XOR_KEY = bytes([0x82, 0x68, 0x12, 0xAF, 0x1D, 0xF6, 0xA3, 0x94, 0x7F, 0x6C, 0x13])
# <-- actual bytes masked; there is exactly one key and it never changes
data = open(filepath, 'rb').read()
magic = struct.unpack('<I', data[0:4])[0] # 0x EDCAF50B (0B F5 CA ED, little-endian)
type_id = struct.unpack('<I', data[4:8])[0] # a CATEGORY label confirmed NOT a key
payload = data[8:] # skip 4-byte magic + 4-byte category id
decoded = bytes(payload[i] ^ XOR_KEY[i % 11] for i in range(len(payload)))Confirmed facts: no key-derivation function, no machine binding, no DPAPI, no AES or RC4 at this layer. The 4-byte category field does not change the key the same 11 bytes decrypt everything.
Recovering the key required no secret. Tar archives are null-padded; XOR of known-zero plaintext against the ciphertext leaks the keystream, and since the key repeats every 11 bytes, the keystream is the key:
# keystream_test.py
# Tar filename fields are null-padded, so ciphertext over those nulls == raw keystream.
payload = open(lunar_tar, 'rb').read()[8:]
key11 = bytes(payload[10 + i] for i in range(11)) # 11 bytes lifted from the padding region
decoded = bytes(payload[i] ^ key11[i % 11] for i in range(len(payload)))
# -> a valid 'ustar' tar header appears. Confirmed.That is a known-plaintext attack on a repeating-key XOR a classic textbook exercise, applied to a shipping enterprise security product.
Proof: Hey share your dotfiles§


With only is possible to filter out what drivers are instantly flagged by the EDR.
Rules: RC4 with a key that ships in the box§
The 307 Lua rules for detecting malware are RC4 encrypted and base64 encoded within the stubs return { "..." }. The RC4 encryption key was obtained in base64 encoding from the lunar_loader.lua of the very agent itself the product utilizes this key to protect itself:
# batch_decrypt_rules.py (redacted excerpt)
import base64
def rc4_crypt(data, key):
S = list(range(256)); j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
i = j = 0; out = bytearray(len(data))
for k in range(len(data)):
i = (i + 1) % 256; j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
out[k] = data[k] ^ S[(S[i] + S[j]) % 256]
return bytes(out)
key = b'NeverGonna…redacted…' # shipped in lunar_loader.lua; you can guess the rest
plaintext = rc4_crypt(base64.b64decode(extract_first_quoted_string(content)), key)Confirmed: with that one key, all 307 rules and the container (202 behavioral-indicator IDs, rule metadata, event maps) decrypt to readable Lua.
Proof: Yet another place where LUA is used§


YARA: XOR 0xFF§
The compiled YARA corpus uses a custom container with magic A6 BE AD BE literally YARA with every bit flipped:
# yara_rule_extractor.py (redacted excerpt)
MAGIC_XOR = b'\xA6\xBE\xAD\xBE' # 'YARA' inverted
def deobfuscate(data): return bytes(b ^ 0xFF for b in data) # A6^FF='Y', BE^FF='A', ...Why this is the finding that matters§
Poor obfuscation of the rules is very common and even survivable, and by itself, analysts will always get at least the rules eventually. The identified issue is one of complete and total lack of cost associated with getting at the confidential data. Recovery of one XOR key for null-padding, one RC4 key which shipped inside the binary, and a one-line YARA transform reveals everything the product knows locally, including all policies, all thresholds, and all trust rules to any person who can read the files. There is no incremental cost difference between "install the agent" and "read everything the agent knows."
This product, which is entirely predicated on the attacker's inability to predict or mitigate the detections of the product, does not merely have a hardening problem in this area – there is simply nothing keeping this data from leaking out. (Note scope: This is just a confidentiality vulnerability – an attacker reading the information in the file system. Files are ACL protected and the agent has full privileges to access them.)
Proof: This was too easy to get§


Finding #2 The behavioral ML model is a readable decision chain gated on one feature§
primaryBehavioralModel.bin (~500 KB) is marketed as machine learning. Confirmed by parsing: it is a 65-condition sequential decision chain a flat pool of 4-byte values reinterpreted as thresholds, leaf scores, or packed decision nodes. The parser is short enough to read whole:
# behavioral_model_harness.py (redacted excerpt)
FEATURE_BYTES = {0x44, 0x45, 0x46, 0x47, 0x61} # the only feature IDs present: 68,69,70,71,97
pool = [u32(raw, 12 + i*4) for i in range((len(raw) - 12)//4)]
internals = {}
for i, v in enumerate(pool):
feat = (v >> 24) & 0xFF
if feat in FEATURE_BYTES: # this slot is a decision node
thresh_idx = (v >> 16) & 0xFF # -> threshold float in the pool
right_child = v & 0xFFFF # -> leaf score (the "detected" branch)
internals[i] = (feat, thresh_idx, right_child)
# left child is implicit: i + 1 (fall through to the next condition)Two confirmed structural facts:
Only five features are used {68, 69, 70, 71, 97}. Everything else the pipeline can collect is unused by the shipped model.
A particular feature sits at the head of the chain. Feature 68 is evaluated in the first 43 out of 65 conditions, and its initial threshold is 2.0. The evaluator exits on meeting the threshold for a feature, and therefore fails otherwise. Thus, the model checks whether "is feature 68 >= 2?" and fails to check features 69, 70, 71, and 97 in the remaining 43 conditions of the same feature.
This was confirmed empirically with a binary search over each feature in isolation:
# evasion_analysis.py (redacted excerpt)
for feat in all_features:
lo, hi, trigger = 0, 10000, None
while lo <= hi:
mid = (lo + hi) // 2
features = {f: 0.0 for f in all_features} # every other feature = 0
features[feat] = float(mid)
if score_path(pool, chain, features)[0] >= 0.5:
trigger = mid; hi = mid - 1
else:
lo = mid + 1
# Result: only feature 68 ever flips the verdict from a cold start.
# With feature 68 = 0, features 69/70/71/97 swept 0..10000 never trigger.Also verified: some later conditions have negative thresholds (−0.6 to −0.9) against counters that can never be negative because of construction conditions that can never trigger. Dead code.
The confirmed conclusion is specific yet significant: this model’s output boils down to one feature. If feature 68 is below 2, then the model will always output “clean” regardless of the rest of the input. (Inference, not confirmation: It looks like SentinelOne’s API hooks are cross-referenced and feature 68 corresponds to inter-process memory accesses, although the exact meaning of features 68 through 97 lives in native code, not in the model file, and was not completely confirmed.)
The second model, called “Discovery AI,” is far more serious, a 9,289 tree DAG forest containing 162 thresholds, and the features themselves live in native code, not in the model file. Verified: it is controlled by a single configuration flag, discoveryAIConfig.telemetriesEnabledGroup, located in the trivially-decrypted InstallationConfig.json from Finding #1.
Proof: A Machine That Learns§

Finding #3 The entire trust model is human-readable§
Decrypting the Lua tarball yields Matchers.lua: 2,147 lines of plaintext Lua defining 215 named matchers the allow-lists that exempt trusted software from specific detections. Each entry is { path, publisher, description, signingType }, where L(...) is a case-insensitive wildcard:
-- Matchers.lua (verbatim excerpt public-name binaries only)
Matchers.svchostMatchers = {
{L('%SystemRoot%\\System32\\svchost.exe'), L(''), L('Host Process for Windows Services'), 'SystemEKU'},
{L('%SystemRoot%\\SysWOW64\\svchost.exe'), L(''), L('Host Process for Windows Services'), 'SystemEKU'}
}The problem isn't the existence of the allow-list, all EDRs have one, but rather that the entire trust model is shared with the endpoint in essentially plain text, so any analyst can simply parse the file and figure out what is allowed and what is blocked. Confirmed contents include:
- 27 commercial attack simulators named explicitly (Cymulate, Picus, AttackIQ, SafeBreach).
- 28 AV/security vendors matched with the
?ALL_PATHS?wildcard path is irrelevant; the signature is the only gate. - A global Microsoft exemption (
msMatchers) granting?ALL_PATHS?to Microsoft-published binaries. - 50+ backup products (Veeam, Veritas, Acronis, CommVault, Datto, Arcserve, …).
- 8 forensic memory-acquisition drivers whitelisted as
memoryAccessDrivers(WinPMEM, DumpIt, FTK Imager, Redline, EnCase, Memoryze, …). - Named LSASS-access exemptions for several EDR/DLP products, direct-syscall exemptions scoped to Office, and a single
RobloxPlayerBeta.exeentry that whitelists an LDR callback-trap bypass.
The importance of the signingType is confirmed by the following facts: the value SystemEKU mandates the use of a particular Windows component EKU certificate in the chain (an actual limitation), while NoEKUs accepts any code signing certificate and the empty value disables the whole signature validation. The fact that the defender has access to this table is helpful; the fact that everyone with access to the agent does is alarming.
Proof: This is not good to know…§

Finding #4 Detection logic is fully legible, including its own gaps§
Because the rules decrypt to source, the agent’s detection decisions can be read directly. A grep across the decrypted corpus enumerates the primitives; reading individual rules confirms how they decide:
# batch_decrypt_rules.py (redacted excerpt) after RC4-decrypting all 307 rules:
patterns = {
'Injection': ['CreateRemoteThread', 'WriteProcessMemory', 'VirtualAllocEx'],
'LSASS': ['lsass', 'Lsass', 'LSASS'],
'Syscall': ['NtCreate', 'NtOpen', 'NtWrite', 'ZwCreate'],
'ETW/AMSI': ['etw', 'ETW', 'amsi', 'AMSI'],
}By reading the source it confirmed several desgin behaviors tha are simply the how the code is written, no exploitation involved:
- Silent event drop on unresolved source UID. In
ows_model_interface.lua, ifevent.source.uid == nil, the event is dropped; the code comment states it is “not possible to send a detection on them even if a rule matches.” OncePerProcess/OncePerGroupindicators exist and, by definition, fire once per scope.- Custom process blaming is disabled.
is_custom_blaming_on()unconditionally returnsfalse, guarded by aWIN-76915TODO. Blame always falls on the source process. - Detection registration is version-gated. Large portions of rule logic register only on newer agents (e.g. registry dynamic rules >= 25.2, behavioral-indicator filtering >= 26.1); older agents log “did not register … because version is not above X.”
Shadow32.dllhooks exactly 6 user-mode APIs (fiveRtl*Heapvariants plusCreateToolhelp32Snapshot) across ntdll/kernel32, with no syscall-layer hooks confirmed by disassembly. It also carries documented, string-labeled off-switches for its own hooks (predicate failure, policy exclusion, DLL version mismatch, per-thread TLS state).
These are stated here as confirmed properties of the shipped code. Turning any of them into an operational bypass is left unwritten the point of this post is the legibility, not a playbook.
The Final Blow§
I think it’s time to show how could an attacked use the findings:
The alert was generated but was miss-classified as a: samSung.bat - Enumeration using a WMI query detected This shows that rules have gaps that even the cloud can't resolve
Why this is grave§
This laundry list builds upon itself, and all of it stems from the same common problem:
- The obfuscation leaks the rules, rendering any detection, threshold, and policy readable offline.
- The obfuscation leaks the configuration; the flags controlling entire subsystems are readable in plaintext.
- The machine learning model reveals its decision to one countermeasure; its decision process is a short and readable sequence of operations.
- The trust model reveals its weaknesses; the allow-list enumerates exactly what is trusted and how loosely.
The threat model of the EDR assumes that the endpoint will fall into the hands of an attacker – that is the entire justification for this product. Given this assumption, protecting the detection logic against disclosure by using a fixed XOR key and in-box RC4 key means that a mediocre analyst quickly reaches parity with the vendor in terms of understanding what the agent searches for. Being able to identify exactly what is and is not detected is exactly the kind of reconnaissance necessary to turn a generic payload into a targeted attack.
Note that this is a limited consideration: cloud intelligence, server-side correlation and SOC analyst are not in the scope here, and they do matter. Quite a lot of silent verdicts also generate telemetry in the upstream side, and a good SOC can catch anything that is not stopped by the local decision-making logic. However, local confidentiality of the detection logic is the expected behavior of the agent, which is not provided in this build.
What defenders should take from this§
- Assume your adversary will be able to read the same rules and allow-lists as you can. Consider your detection content locally to be effectively available to all those who have access to the agent, and periodically verify that trusted paths, trusted publishers, and trusted descriptions from the matcher set cannot be trivially fulfilled by untrusted binaries.
- Rely on the cloud/SOC level. Local detection is one factor; central correlation is where hidden telemetry comes into play. Make sure it is really being correlated.
- Ask the right questions of your vendors: Is there something stronger than a static, in-product key that protects your local detection content? How does the model change its verdict if a single feature is disabled?
A note on responsibility§
This was offline analysis of a shipping build, for defensive research. Every code excerpt is redacted keys masked, personal paths replaced with placeholders and no operational bypass is spelled out. The most important fixes are entirely in the vendor’s hands: protect local detection content with keys that do not travel inside the product, and stop letting a single feature act as a master switch for a model marketed as ML.
The uncomfortable takeaway is not that this agent can be studied. It is that studying it required so little. Trust in a security product should rest on its design surviving scrutiny not on scrutiny being hard.
Analyzed build: SentinelOne Agent 26.1.2.177 (Windows).
Confirmed via Ghidra plus ~10–60-line Python decryptors/parsers for the XOR config format, the RC4 rule format, the XOR-0xFF YARA container, both ML models, the RocksDB scan-results store (55,316 entries), and the behavioral-indicator mappings. Inferences are labeled as such; code excerpts are redacted for responsible publication.