how do you find a sharepoint deserialization bug?
sharepoint had another bad summer. CVE-2026-50522: unauthenticated code execution, CVSS 9.8, public PoC on july 20, exploitation showing up in honeypots within hours of it dropping. one year after toolshell, same bug class, same panic, same “we patched, are we ok?” thread in every corporate slack.
you can read the advisory elsewhere. what i want to walk through is how anyone finds one of these in the first place, in a product whose source you’ll never see.
the dangerous call in this bug is BinaryFormatter.Deserialize. it’s been in that code path for over a decade, never hidden, never obfuscated, never renamed. anybody who decompiled the assembly could see it. so what did the finder actually find?
table of contents
open table of contents
what the request looks like
one request, start to finish:
attacker sharepoint (w3wp.exe) │ │ │ POST /_trust/default.aspx │ │ wa=wsignin1.0 │ │ wresult=<RequestSecurityToken │ │ Response> ... │ │ ──────────────────────────────────> │ │ │ federation sign-in. │ │ runs before identity │ │ exists. that's the │ │ entire job of the │ │ endpoint. │ │ │ ├─ parse the RSTR xml │ ├─ pull out SecurityContextToken │ ├─ take its <Cookie> value │ ├─ base64 decode │ ├─ inflate (deflate transform) │ └─ deserialize ◄── the sink │ │ │ ◄──── oob callback ───────────────────────────┘ │ gadget chain fires, │ as the app pool identitythat’s the whole exploit. a stock ysoserial.net chain, deflated, base64’d, wrapped in a bit of WS-Federation xml, and posted at an endpoint that by design has to talk to strangers.
affected builds, since somebody always asks:
| product | fixed in |
|---|---|
| sharepoint server subscription edition | 16.0.19725.20434 |
| sharepoint server 2019 | 16.0.10417.20175 |
| sharepoint enterprise server 2016 | 16.0.5561.1001 |
why BinaryFormatter is code execution
BinaryFormatter builds objects, and the payload decides which types get built. that’s the whole problem, and it’s worth spelling out because “deserialization is dangerous” gets repeated a lot without meaning anything.
| json parser | BinaryFormatter | |
|---|---|---|
| what comes back | strings, numbers, lists | an object graph |
| who picks the types | the parser | the payload |
| what runs while parsing | nothing | constructors, property setters, OnDeserialized, IObjectReference.GetRealObject, finalizers |
| worst case on bad input | a parse error | whatever the type author wrote in those callbacks |
that last row is the attack. a gadget chain is a sequence of types whose reconstruction, in the right order, ends somewhere useful. TypeConfuseDelegate and friends bend a delegate into invoking Process.Start for you.
so any code path where an attacker controls the bytes going into BinaryFormatter is code execution. that’s the documented behaviour of the class, not a maybe. microsoft has been telling people to stop using it since 2020 and marked it obsolete-with-an-error in .NET 5.
which brings us back to the question. if this is that well known, and the call sits right there in a decompiler, how did it survive a decade of people looking at sharepoint?
it was guarded by an assumption, and assumptions don’t show up in a decompiler.
why it survived a decade
the session token cookie is a legitimate feature. WIF needs to keep your session across requests, and it does that by serializing a SessionSecurityToken into a cookie. deserializing it on the way back in is the point of the thing.
what makes it safe is the cookie transform chain wrapped around it. by design, a session cookie gets signed and encrypted on the way out, and gets the mirror of that on the way in:
what the design assumes what this path actually did ───────────────────────── ─────────────────────────── cookie bytes cookie bytes │ │ ├─ verify signature │ (nothing) ├─ decrypt │ (nothing) ├─ inflate ├─ inflate └─ deserialize └─ deserialize ▲ ▲ │ │ to get here you need to get here you need the farm machine key an http clientthe deserializer never knew the difference. it got bytes, it built objects. it was safe for years because the only thing that could produce bytes it would accept was the server itself, holding the ValidationKey. the security lived upstream and out of frame.
then a code path shows up where the same blob arrives from a federation response, and the transform chain on that path is deflate-only. no signature, no encryption, just decompress and hand it over. the sink didn’t change. the guard was never attached to it in the first place.
sixty years on a shelf. what kept it safe was that nobody walked in.
a dangerous sink is rarely protected by something next to it. it’s protected by a reason. the reason lives in someone’s head, or in a design doc, or in a different assembly. bugs happen when a new caller arrives and the reason doesn’t arrive with it.
why closed source isn’t the obstacle
before any of that, check how much of the target is actually closed.
the deserializer in this bug lives in SessionSecurityTokenHandler, which ships as part of .NET Framework, and microsoft publishes that source. it’s on github, all of it:
ReadTokenruns the cookie bytes back throughApplyTransformsin reverse order, then hands the result toBinaryFormatter.Deserialize()- the default transform collection is
new DeflateCookieTransform(), new ProtectedDataCookieTransform(). deflate, then DPAPI. that default is fine - one constructor overload takes a
ReadOnlyCollection<CookieTransform>of the caller’s choosing, andSetTransformsis protected for subclasses
read that file and the shape of the bug is already visible. the sink is public, the guard is public, and both are written correctly. what makes any given instance unsafe is which transform collection the caller hands to the constructor.
that decision is the only part that’s closed.
| the question | where the answer lives |
|---|---|
| what does the sink do | open. SessionSecurityTokenHandler.cs on github |
| what protects it by default | open. deflate plus DPAPI, in DefaultCookieTransforms |
| can a caller replace that | open. the constructor overload says yes |
| what does sharepoint pass in on the federation path | closed. one decompile, one find-references |
that narrows the job to a single question inside sharepoint.
and .NET makes that question cheap to answer. IL metadata keeps type names, method names and parameter names, so a decompiler hands back something close to the original c# rather than the pseudo-code you’d fight through on a c++ binary in ghidra. sharepoint ships unobfuscated. on top of that a cumulative update lands every month, and diffing the assemblies on either side of one points straight at whatever microsoft just decided to change.
how you find one yourself
the loop is the same one the OSWE labs teach, source to sink. it just runs over decompiled IL instead of a repo.
1. get the code
sharepoint is a .NET application that happens to cost a lot of money. everything you need is sitting on the box:
C:\Program Files\Common Files\microsoft shared\Web Server Extensions\16\ISAPI\C:\inetpub\wwwroot\wss\VirtualDirectories\<port>\batch-decompile to disk instead of clicking through ILSpy’s tree. ilspycmd is the same decompiler as the gui, and text on disk is what the rest of this workflow needs:
$isapi = "C:\Program Files\Common Files\microsoft shared\Web Server Extensions\16\ISAPI"$out = "C:\kagebunsher\sp-src"
Get-ChildItem $isapi -Filter "Microsoft.*.dll" | ForEach-Object { ilspycmd -p -o "$out\$($_.BaseName)" $_.FullName}pull the identity stack too (Microsoft.IdentityModel.*, System.IdentityModel.*) from the GAC. that’s where this particular bug lives, and it’s where the next one will live as well.
the output will be ugly in places, with compiler-generated state machines, async rewrites and inlined closures. doesn’t matter. you’re not rebuilding the product, you’re answering “who calls this”.
and grab a second copy from a patched box. diffing two decompiled trees is how a good share of these get found, and git does it for free:
git diff --no-index --stat C:\kagebunsher\sp-src-prepatch C:\kagebunsher\sp-src-postpatch--stat ranks files by how much changed. security fixes are usually small and surgical, so a two-line diff inside an identity assembly is a much better lead than a fresh target and a hunch.
2. sweep for sinks
this part is mechanical. do it once and keep the list forever. for .NET, the deserialization sinks that hand type control to the payload:
| sink | notes |
|---|---|
BinaryFormatter.Deserialize | full type control. game over on reach. |
NetDataContractSerializer.Deserialize | same, types travel in the payload |
SoapFormatter.Deserialize | ancient, still shows up |
LosFormatter / ObjectStateFormatter | viewstate. the toolshell family. |
JavaScriptSerializer with SimpleTypeResolver | the resolver is what makes it dangerous |
Json.NET with TypeNameHandling != None | the most common one in real-world code |
DataContractSerializer with a permissive resolver | needs the resolver to be sloppy |
XmlSerializer where the type is attacker-influenced | classic sharepoint 2019 territory |
DataSet.ReadXml / typed datasets | nested type confusion, its own whole thing |
run the whole list across the decompiled tree at once, and group by file so you get a ranked worklist instead of a wall of matches:
$sinks = 'BinaryFormatter','NetDataContractSerializer','SoapFormatter', 'LosFormatter','ObjectStateFormatter','SimpleTypeResolver', 'TypeNameHandling','\.ReadXml\('
Get-ChildItem C:\kagebunsher\sp-src -Recurse -Filter *.cs | Select-String -Pattern ($sinks -join '|') | Group-Object Path | Sort-Object Count -Descending | Select-Object Count, @{n='file';e={Split-Path $_.Name -Leaf}}you’ll get hundreds of files back. that’s expected, and it’s why the list alone is worthless. every one of those hits has been there for years and is presumed fine. a finding that has always existed and has always been dismissed reads as noise, and gets treated like noise.
the list is your starting set, not your answer.
3. map the pre-auth surface separately
do this without thinking about sinks at all. what can an unauthenticated request reach?
- handlers under
/_layouts/,/_vti_bin/,/_windows/,/_trust/ <location>blocks inweb.configthat allow anonymous- anything in the identity or federation path. it has to answer strangers, because that’s what sign-in is
- endpoints reached before the request even hits the auth module: http modules, form digest validation, and whatever runs earlier in the pipeline than you assumed
that last one is where toolshell lived, an auth bypass into ToolPane.aspx, and it’s where this one lives too, except no bypass was needed. federation sign-in is supposed to be pre-auth. nobody bypassed anything, they used the front door for its intended purpose.
4. interview every assumption
now overlay the two sets. for every sink reachable from the pre-auth surface, ask one question: why is this one considered safe?
write the answer down as a sentence rather than shrugging at it. you’ll get one of about four answers, and every one of them is a claim about the world rather than about the code in front of you:
| the claim | what it actually asserts | how you check it | how it held up here |
|---|---|---|---|
| “the blob is signed, only we can produce it” | a signature check runs upstream | find the check. does it run on this call path, or only the one you read? | the /_trust/ path inflates and deserializes. no signature step at all |
| “the type is fixed by a binder” | a SerializationBinder restricts what gets built | is the binder installed on this instance? read its allow-list, not its existence | nothing restricting types on the path |
| “it’s only called from the internal admin path” | reachability is limited by the caller | list every caller, not the one that made you look | federation sign-in reaches the same code |
| “the caller already validated the session” | auth ran earlier in the pipeline | does the pre-auth surface reach the same method by another route? | sign-in runs before identity exists. that is its job |
the bug shows up the moment one of those claims turns out to be true on three code paths and false on the fourth. that last column is the entire finding. the other three columns had been true for a decade.
5. prove control, not just reachability
reaching a sink isn’t enough. plenty of paths reach a deserializer with bytes you can’t meaningfully steer: length caps, a binder that only allows two types, a wrapper that catches and drops.
| what you observed | what you actually have |
|---|---|
| the sink is reachable from a pre-auth route | a path |
| you control the bytes, but a binder blocks your type | a path, and a binder to go read |
| a benign gadget fires an out-of-band callback | a bug |
| you sent it and nothing came back | a path. write it up as a path, in those words |
the honest test is boring. put a benign gadget in, watch for the callback. dns is the friendliest oracle here because it survives egress filtering that kills http.
what microsoft’s fix tells you
the july cumulative update (KB5002882) doesn’t add a signature check to that path. it swaps the deflate-only cookie transform for one that throws.
that’s the right fix, and it’s worth noticing why. once an attacker controls the bytes going into BinaryFormatter, there’s no such thing as validating them safely. you’d be deciding whether an arbitrary object graph is friendly before you’ve finished constructing it, which is the same trick the gadget chain is already using against you.
so when you write one of these up:
| recommendation | verdict |
|---|---|
| “validate the input” | wrong. you can’t validate an object graph you’re already building |
“add a SerializationBinder allow-list” | helps, and has a long history of being escaped by whatever type quietly made it onto the list |
| “make the path not reach the sink” | this is the fix |
| “move to a format that doesn’t carry types” | this is the fix, one release later |
closing thoughts
the sink inventory takes an afternoon. every dangerous sink in a mature product has already been found and dismissed by somebody, and what nobody wrote down is why they dismissed it.
so write it down. one sentence per sink, then check that sentence against every caller instead of the one that made you write it.