Introduction
Hello everyone!
It has been almost two months since I last published anything and, honestly, I missed writing. I am back to share a small project I have been working on recently: Margaret-Stealer.
Margaret is an approximately 13 kB Windows x64 shellcode project that explores access to cookies held in the memory of Chrome’s network utility process.
The idea came from a simple intuition that is nevertheless worth considering. If Chrome’s network utility process must natively access session cookies for obvious reasons, why not reuse that capability for purposes that interest us? :)
Cookie values are encrypted in memory, but sooner or later Chrome still needs to recover them in plaintext, right?
If we understand how those values are recovered, we can reuse that same routine for our own purposes.
The implemented path concerns both session and persistent cookies resident in memory and takes advantage of the process’s ability to access its own protected values.
Understanding the project requires connecting three elements: the cookie container, protection of the cookie value, and recognition of internal structures after the browser has been recompiled.
flowchart TD
A["Chrome network utility process"] --> B["CookieMonster manages cookies"]
B --> C["CanonicalCookie represents each cookie"]
C --> D["ProcessBound protects the in-memory value"]
E["Margaret identifies structures and routines in the build"] --> B
E --> F["Access to the value inside the process that owns it"]
D --> F
F --> G["Temporary plaintext value"]
G --> H["Results returned to the loader"]
Figure 1 — Logical path through the project, from cookie management to temporary production of the plaintext value.
The diagram describes the path studied by the project. The interesting part is the relationship between in-memory data protection and the code that must be able to use that data to perform its normal work.
CookieMonster: the cookie manager
net::CookieMonster is the Chromium class responsible for storing, retrieving, expiring, and deleting cookies. It implements the CookieStore interface and coordinates in-memory state with an optional persistent store. A cookie stored on disk and the corresponding object used during an HTTP request therefore belong to different layers of the system. Chromium documentation for CookieMonster.
CanonicalCookie predates the introduction of ProcessBound and does not represent the current layout.The central container for unpartitioned cookies, CookieMap, maps a domain-derived key to CanonicalCookie objects. In the source revision examined here, it is a std::multimap whose values are std::unique_ptr<CanonicalCookie> instances: CookieMonster owns the objects and controls their lifetime. A multimap allows several cookies to share the same grouping key. Partitioned cookies use a separate structure that is also indexed by the partition key. CookieMap and PartitionedCookieMap declarations.
CookieMap definition: a std::multimap that associates a domain key with CanonicalCookie objects owned through std::unique_ptr.This distinction also matters when interpreting Margaret’s results. Its engine traverses the container structure that it recognizes; the code does not demonstrate general coverage of every partitioned map or every profile. A collection obtained from memory must not automatically be treated as all cookies present in the browser.
CanonicalCookie: container and contents
CanonicalCookie represents one cookie. It exposes properties such as its value, expiry, and access times, while part of the metadata is inherited from CookieBase. The word “canonical” refers to the object’s representation and validation rules; it does not identify a particular cryptographic format.
CanonicalCookie: it describes a serialized representation, not the in-memory C++ object.In the Chromium source examined here, the value is a std::optional<crypto::ProcessBoundString>. This separates the cookie metadata from the protected representation of its contents. The Value() method returns a value that callers can use, and its declaration warns that access may require decryption. CanonicalCookie.
CanonicalCookie field: the value is stored as a std::optional<crypto::ProcessBoundString>.
CanonicalCookie interface: Value() returns a string that the caller can use.
CanonicalCookie::Value(): it returns an empty string when no value is present; otherwise, it delegates to ProcessBoundString::value().Within the project, CookieMonster therefore provides a structural reference point, while CanonicalCookie is the object to interpret. This organization is more meaningful than a plain text search in memory: relationships between the objects allow a value to be associated with its metadata.
How ProcessBound protects the value in memory
crypto::ProcessBound<StringType> is a C++ class template, not a single function. ProcessBoundString is the alias for its std::string specialization. The object stores a copy of the contents, the original length, and a flag indicating whether protection succeeded. ProcessBound and SecureAllocator.
When the value is stored
The constructor receives a string and copies it into an internal buffer. On Windows, MaybeRoundUp() rounds its size to the multiple required by CryptProtectMemory(). MaybeEncryptBuffer() then attempts to protect the buffer in place with CRYPTPROTECTMEMORY_SAME_PROCESS; the result is stored in the object’s flag. Windows implementation of ProcessBound.
ProcessBound: copying the value, rounding the buffer size, attempting protection, and storing the result.If the platform does not support this operation or encryption fails, the flag remains false and the wrapper still keeps the copy, but without process-bound protection. ProcessBound does not implement its own AES algorithm here and does not manage an application key to recover: it delegates memory protection to the operating system.
When the value is requested
The value() and secure_value() accessors both pass through the internal DecryptData() routine. If the buffer is protected, the routine prepares a copy, calls MaybeDecryptBuffer() to decrypt it in place, and finally restores the string to its original length, removing any padding. The buffer stored in the object can therefore remain protected while the caller receives a usable copy.
DecryptData() path: handling an unprotected value, copying the buffer, decrypting it in place, and restoring the original length.If protection did not succeed, or if the string is empty, no decryption is required: a copy limited to the original length is returned directly.
value() returns an ordinary string, which may leave remnants in memory after use. secure_value() instead returns a string backed by SecureAllocator, whose deallocation method attempts to wipe the buffer before releasing it. This reduces the lifetime of residual data, but it does not make the contents unreadable while they are in use and does not protect copies created elsewhere.
The Windows security boundary
On Windows, MaybeEncryptBuffer() and MaybeDecryptBuffer() delegate to CryptProtectMemory() and CryptUnprotectMemory(), respectively, using CRYPTPROTECTMEMORY_SAME_PROCESS. This mode indicates that the buffer is intended to be recovered within the same process that protected it; the API also requires the size to be a multiple of its block size. CryptProtectMemory, CryptUnprotectMemory.
ProcessBound: buffer alignment and calls to CryptProtectMemory() and CryptUnprotectMemory() with CRYPTPROTECTMEMORY_SAME_PROCESS.This path must not be confused with DPAPI/App-Bound protection of the cookie database on disk. The memory APIs are declared in dpapi.h, but Margaret neither retrieves nor decrypts the persistent database: it operates on the value already managed by ProcessBound inside the network utility process.
Why plaintext must still exist
When Chrome needs to use the cookie, value() must produce a readable representation. Protection therefore limits exposure of the buffer stored in the object, but it does not eliminate the plaintext copy returned to the caller.
This does not mean that every cookie always remains in plaintext in RAM. It means that normal use requires a readable representation at least temporarily, and that code executing within the same process can act at that moment.
In the Chromium revision examined here, CanonicalCookie::Value() returns an empty string when the optional value is absent; otherwise, it calls value() on its ProcessBoundString. This delegation directly connects the cookie to the path described above. Implementation of CanonicalCookie::Value.
Margaret identifies the implementation of this accessor in the builds under analysis and invokes it inside the process that owns the value.
Why fixed addresses make the project fragile
A function can retain the same meaning while moving to a different location in the binary. An update may change function ordering, inlining, the registers selected by the compiler, and the layout of structures.
ASLR changes a module’s load base. Recompilation can instead change offsets inside the module: knowing the base does not solve this second problem. Even two images with the same size may contain differently arranged code.
Margaret’s README describes an update between two patch releases in which the image size remained unchanged, but a previously recorded reference no longer pointed to the correct start of the code. The resulting architectural decision was to discover the references at runtime. Project architecture and rationale.
Byte scanning and wildcards
Byte scanning compares a region of memory against expected characteristics. An exact match requires every byte to be equal; a masked match leaves selected parts variable.
A wildcard represents a byte that is not constrained by the exact comparison. It can be useful in recompiled code when an operand changes while the kind of operation remains recognizable. It does not mean that any instruction at that position is equivalent: the more freedom the matcher allows, the greater the risk of finding an unrelated sequence.
Margaret does not include a generic engine that parses a pattern string containing ? characters. Wildcard behavior is expressed directly by C conditions: some bytes are compared exactly, some accept sets or masks, and selected variable portions are not used as fixed signature bytes. This distinction matters when comparing the notation used to describe a pattern with its actual implementation.
The rest of this post uses compact notation to describe the conditions implemented in src/adapter.c:
| Notation | Meaning |
|---|---|
?? |
Any byte: the signature does not constrain its value |
[AA-BB] |
One byte within the inclusive range |
{AA,BB} |
One byte selected from the explicit set |
rel32 |
Four bytes interpreted as a signed displacement relative to the end of the instruction |
Before looking for instructions, Margaret bounds the two regions of chrome.dll that matter. Their names are eight-byte PE fields padded with zeroes:
| Section | Exact signature | Purpose during scanning |
|---|---|---|
.text |
2E 74 65 78 74 00 00 00 |
Identifies the executable-code region in which instruction shapes and references are searched |
.rdata |
2E 72 64 61 74 61 00 00 |
Identifies the read-only-data region in which the string anchor and vtable pointers are searched |
Inside the blob, these names are stored in encrypted form and decrypted into a stack buffer before comparison. The signature therefore describes the bytes searched for in the Chrome module, not their encrypted representation inside Margaret.
The scanner operates on module sections and combines code-shape recognition with validation of the resulting candidates. It is not a complete disassembler: recognizing a byte sequence does not, by itself, prove that it represents the desired function.
Resilience comes from reducing dependence on one particular binary layout. Assumptions about instruction shapes, structures, and compiler behavior still remain. A refactor can invalidate them even if the function continues to perform the same task.
Reading the assembly sequence
The shape being searched for represents a small sequence that accesses the protected value. It is not a signature for an entire function: several clues must appear within the same code window. The first shape identifies an access to the field at +0x108; a second identifies the address of the value at +0xE0; finally, a call yields a possible destination.
| Stage | Exact accepted signature | Simple meaning | How it is used during scanning |
|---|---|---|---|
| Status field, legacy base registers | 80 [B8-BF] 08 01 00 00 ?? |
In its ordinary form, this is cmp byte ptr [register+0x108], immediate: it checks the byte describing the value’s state |
The scanner first searches for the four little-endian bytes of offset 0x108, then checks the immediately preceding opcode and ModRM. The final byte may have any value |
| Status field, REX.B branch | 41 80 [B0-B7] 08 01 00 00 ?? |
This is a second form accepted by the current code. ModRM /6 actually describes an xor, not a cmp |
It is treated as a possible start of the same window. This distinction matters: the table documents what the matcher actually accepts |
Value address, lea form |
48 8D {88,89,8A,8B,8D,8E,8F} E0 00 00 00 |
Computes in rcx the address of the member at +0xE0, without reading its contents yet |
It must appear in the window that begins at the +0x108 check. ModRM value 8C is deliberately excluded because it requires a SIB byte |
| Value address, imm32 form | 48 81 C1 E0 00 00 00 |
Adds 0xE0 to rcx, obtaining the same address through a different instruction form |
Accepted as an alternative to lea |
| Accepted short-add form | 48 83 C1 E0 |
This form is present in the matcher, but the 8-bit immediate is signed: the instruction means add rcx,-0x20, not add rcx,0xE0 |
The current code nevertheless considers it a match; this is an implementation property, not a semantic equivalence |
| Candidate call | E8 ?? ?? ?? ?? |
E8 is a relative call; the four variable bytes describe the distance to the called function |
Margaret uses the first E8 in the permitted window, computes destination = end_of_instruction + rel32, and retains the candidate only if it starts inside .text |
| Target prologue | 41 57 41 56 41 55 41 54 |
Saves r15, r14, r13, and r12 in that order; this is the opening observed for the target routine |
After comparing candidate destinations, the winner’s first eight bytes must match exactly |
The two ModRM ranges contain an edge case: BC in the legacy form and B4 in the REX.B form require a SIB byte. In those cases, 08 is interpreted as the SIB, ?? becomes part of the displacement, and the actual immediate is the following byte, which the matcher does not inspect. These combinations are still accepted because the code compares bytes rather than decoding a complete instruction.
The window starts at the first signature and is at most 56 bytes long. A +0xE0 form may begin at offsets 7–49 within the window; the first candidate E8 may begin at offsets 10–50. The canonical site also contains lea rdx,[rsp+0x30], encoded as 48 8D 54 24 30, which prepares the output on the stack: it helps when reading the assembly, but the current matcher does not test it and it is therefore not part of the signature.
One match is not enough. Margaret tracks at most the first eight distinct destinations, requires at least three votes, and selects the most frequent destination only if it beats the runner-up. This is a unique plurality, not a mathematical majority of all sites. The fixed prologue is the final check.
How Margaret identifies the references of interest
Discovery answers two separate questions: which structure identifies the container, and which routine provides access to the protected value.
For the container, the starting point is the name of a CookieMonster method. A vtable is an array of pointers used for C++ virtual-method dispatch; it does not contain cookies, but it provides a reference that can identify objects of the same class.
| Stage | Exact signature | Meaning and runtime search |
|---|---|---|
| String anchor | 43 6F 6F 6B 69 65 4D 6F 6E 73 74 65 72 3A 3A 47 65 74 43 6F 6F 6B 69 65 4C 69 73 74 57 69 74 68 4F 70 74 69 6F 6E 73 41 73 79 6E 63 00 00 |
This is CookieMonster::GetCookieListWithOptionsAsync followed by two NUL bytes. The generator counts 45 bytes, including the first NUL; the current call searches for LEN + 1, so the effective comparison is 46 bytes. Margaret decrypts the anchor on the stack and selects the first occurrence in .rdata |
| RIP-relative reference to the string | {48,4C} 8D {05,0D,15,1D,25,2D,35,3D} ?? ?? ?? ?? |
This is a lea that computes an address relative to rip. For each match in .text, Margaret interprets the final four bytes as rel32 and verifies that the resulting address is exactly the selected string. Exactly one valid reference must exist |
The reference to the string lies inside the method that uses it. From that point, Margaret tries up to 64 possible function starts aligned to 16 bytes: align_down(xref,16) - 16*k, with k from 0 through 63 while the result remains inside .text. Each candidate virtual address is encoded as an exact eight-byte little-endian value and searched for in .rdata. This “signature” therefore has no fixed bytes: it changes with the ASLR base and is constructed at runtime.
When one of these pointers appears as slot 3, Margaret treats the preceding 24 bytes as the candidate start of the vtable. It accepts the result only if slots 0, 1, 2, and 4 also contain addresses inside .text. The string is therefore not mistaken directly for a vtable: it is used to find the method, then its pointer, and finally the table.
For the ProcessBound accessor, Margaret instead uses the signature chain described in the previous section. The shellcode does not read the C++ name from debug symbols: it groups the destinations of matching call instructions, applies the vote threshold, and checks the 41 57 41 56 41 55 41 54 prologue.
These signatures describe the current matcher exactly; they are not a guaranteed property of every future Chrome version. If recompilation changes the instruction shapes, makes the string reference ambiguous, or changes the prologue, discovery fails and the project returns UNSUPPORTED_BUILD rather than selecting an approximate address.
Recognizing the container is not enough: the layout
Identifying a class and knowing the arrangement of its members are separate problems. A layout describes the location of fields in the compiled object; it can change when properties are added, alignment changes, or base classes are modified.
Margaret therefore separates discovery from calibration. The calibrator compares a sample of objects and looks for properties that remain consistent across strings, metadata, and timestamps. It uses between 4 and 16 samples; many checks require agreement from more than half of them.
Comparing several objects prevents a coincidence in one sample from being mistaken for a structural property. If the data does not support an acceptable layout, the path may return UNSUPPORTED_BUILD. The same error can result from an insufficient sample, not only from an incompatible build. Calibration in Margaret.
Discovery and calibration nevertheless have separate limits. If recognition of the routine depends on a code shape that has changed, the ability to adapt the object layout is not enough to make the entire path compatible.
From the value to the results
The payload produces a binary buffer containing snapshot metadata and cookie records. The loader is a separate component: it receives the buffer and produces local JSON exports.
This separation keeps the blob compact and distinguishes reading in-memory state from presenting the results. It does not guarantee that every attribute is reconstructed exactly. Some flags are inferred, the export applies conventional values to unusable expiry dates, and the StorageAce variant always sets session: false. Serialization, loader export.
The process may also change while it is being observed. Traversing memory is not an atomic transaction over the browser’s cookie store, and an apparently complete result does not demonstrate coverage of every context.
Final thoughts
The project’s central theme is the boundary between protecting data and using it. CookieMonster organizes the objects, CanonicalCookie represents their properties, and ProcessBound protects the stored value. Discovery attempts to recognize these relationships after recompilation; its reliability depends on how well the observed assumptions continue to describe the concrete build.
Cross-build reliability comes from relying on source-level structures that Chromium tends to preserve over time and rarely changes—or so the working assumption goes—even when addresses and machine code shift after recompilation.
In essence, the scanner looks for what survives recompilation and identifies the specific patterns worth retaining to make the project as stable as possible.
As for the repo, you can find everything here:
If you’ve made it this far, thank you so much for your attention.
Best wishes,
nora