Corrections are a feature
Three slips in No. 4, owned and turned into lessons on DPAPI-NG, unsafe reflection versus deserialisation, and real limits for AI agents.
Yesterday’s piece, The print server that held the keys to the castle, shipped with three bugs. Not in the print server. In the prose.
We found them ourselves, a day late. A gremlin auditor would have found them sooner, but he was on leave. So here is the patch, with release notes. Corrections are a feature. Silent hotfixes are how you end up with a CVE named after you.
Each slip turns out to be more interesting than the sentence it replaced.
Slip 1: DPAPI-NG is a lock, not a force field
What we wrote:
“Any stored secret is encrypted with DPAPI-NG or a cloud KMS and scoped to the service identity, never readable by SYSTEM-wide tooling. A memory dump yields nothing reusable.”
Two overclaims in two sentences. A personal best.
What DPAPI-NG actually does. DPAPI-NG (the CNG version of Windows’ Data Protection API) encrypts a blob to a protection descriptor: for example SID=<service account SID>, a certificate, or LOCAL=machine. With a SID descriptor in a domain, the keys come from Active Directory’s Group Key Distribution Service, so only that principal can unprotect the data. A cloud KMS does the same job with an IAM policy instead of a SID.
What that protects against: offline theft.
- A copied config file, a stolen backup or a VM snapshot opened elsewhere is ciphertext without a key.
- An attacker who reads the disk but cannot act as the service identity gets nothing.
What it does not protect against: a live compromise.
- SYSTEM, or a local administrator, can usually impersonate the service account, or run code in its context, and ask Windows to decrypt. The API cannot tell your service from someone wearing its badge.
- A running service holds the decrypted secret in memory. Dump the process and it is sitting there in plaintext. Encryption at rest is, by definition, at rest.
So what actually saves you is least privilege. If the service account has no domain privileges, then whatever leaks from memory is a key to a broom cupboard. What it should have said:
Any stored secret is encrypted with DPAPI-NG or a cloud KMS, bound to the service identity, so a copied file or backup is useless off the box. And because the account holds no domain privileges, even a memory dump yields nothing worth reusing.
Nuance: “usually” is doing honest work. Credential Guard, protected processes and HSM-backed keys raise the bar, but they shrink the window rather than close it. Assume the host is lost and design so that losing it is boring.
Slip 2: reflection and deserialisation are cousins, not twins
What we wrote:
“…the same bug class as the reflection flaw.”
Close. Close enough for the family reunion, not for the CWE number.
What a reflection flaw is. Reflection lets a program pick a class or method by name at runtime. In Java:
Class<?> c = Class.forName(request.getParameter("type"));
Method m = c.getMethod(request.getParameter("action"));
m.invoke(c.getDeclaredConstructor().newInstance());
Useful for plugin frameworks. Catastrophic when the strings come from the attacker. The input is no longer data. It is a menu, and the attacker is ordering. MITRE files this as CWE-470, Use of Externally-Controlled Input to Select Classes or Code (“Unsafe Reflection”).
Why unsafe deserialisation is its cousin. Java’s native serialisation rebuilds objects from a byte stream, and the stream names the classes:
ObjectInputStream in = new ObjectInputStream(request.getInputStream());
Object o = in.readObject(); // the bytes choose what gets built
During readObject(), the JVM instantiates whatever classes the stream asks for (if they are on the classpath) and runs their readObject, readResolve and related hooks. Chain enough innocent library classes together, the famous gadget chains in Apache Commons Collections and friends, catalogued by the ysoserial tool, and you reach Runtime.exec() without the application ever calling it. That is CWE-502, Deserialization of Untrusted Data.
The family resemblance:
- Same root: untrusted input decides which code runs.
- Different mechanism: reflection is the application explicitly looking up a name it was handed; deserialisation is the runtime doing it implicitly, mid-parse, before your code sees the object.
- Different fixes, which is why the distinction matters.
The fix for reflection: never resolve attacker-supplied names. Map input to a fixed allow-list (switch or a Map<String, Handler>), and ban Class.forName and Method.invoke on input paths in CI.
The fix for deserialisation: JEP 290. Serialisation filters arrived in Java 9 and were backported to 8u121, 7u131 and 6u141. An ObjectInputFilter (or the jdk.serialFilter system property) allow-lists classes and caps depth, array sizes and object counts before objects are built:
-Djdk.serialFilter=com.example.dto.*;java.base/*;!*;maxdepth=10
Java 17’s JEP 415 adds context-specific filter factories so each stream can get its own rules. Better still, don’t accept native Java serialisation from untrusted sources at all. Use a strict schema (JSON with explicit types, Protobuf) and no polymorphic type hints.
Nuance: a filter is an allow-list, and allow-lists rot. A filter of !* with exceptions is protection; a filter of * with a few bans is a to-do list for the attacker.
Slip 3: the scope list didn’t leak, the restraint didn’t hold
What we wrote:
“The attacker’s own scope list leaked.”
What GreyNoise actually said is that the attacker’s restraint failed in some instances. Nothing leaked. The agent was told what to leave alone and, sometimes, didn’t.
That is the better lesson anyway, because the attacker’s problem is our problem.
A rule in a prompt is advice. The model reads “do not touch 10.0.0.0/8” the same way it reads everything else: as tokens that shift probabilities. It is not a filter on actions. It competes with the task, with tool output, with whatever a hostile web page says, and it degrades over long contexts. Usually it is followed. “Usually” is not a control.
Real limits are enforced outside the agent, where the agent cannot argue with them:
- Identity: the agent runs as its own principal with scoped, short-lived credentials. If the token cannot read the finance share, no prompt injection will make it.
- Network: egress allow-lists, segmentation and a proxy that drops traffic to anything off-scope. The packet gets dropped, not politely declined.
- Privilege: no standing admin rights; tools exposed with narrow parameters; destructive actions behind human approval or just-in-time elevation.
- Evidence: every tool call logged outside the agent’s reach, so “it said it didn’t” can be checked.
The attacker’s agent had a scope list and no fences. Defenders’ agents tend to have the same. A gremlin told not to eat after midnight will, eventually, check the clock and decide it’s a time-zone question.
The patch notes
- DPAPI-NG stops the stolen backup, not the live intruder. Least privilege is what makes the intruder’s loot worthless.
- Reflection and deserialisation share a root (input picking code) and need different fixes: allow-lists for names, JEP 290 filters or no native serialisation for streams.
- Scope in a prompt is a suggestion. Scope in identity, network and privilege is a boundary.
This follows No. 4. If you spot a fourth slip, send it in. We ship fixes.
Sources
- Microsoft Learn, CNG DPAPI and Protection Descriptors
- MITRE CWE-470, Unsafe Reflection; CWE-502, Deserialization of Untrusted Data
- OpenJDK, JEP 290: Filter Incoming Serialization Data; JEP 415: Context-Specific Deserialization Filters
- OWASP, Deserialization Cheat Sheet
- GreyNoise, AI-orchestrated campaign against PaperCut NG/MF, 9 September 2026: greynoise.io
