← home

how do you find a sharepoint deserialization bug?

· 11 min read
legolas explaining to gimli that the gate has stood open for an age

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 identity

that’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:

productfixed in
sharepoint server subscription edition16.0.19725.20434
sharepoint server 201916.0.10417.20175
sharepoint enterprise server 201616.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 parserBinaryFormatter
what comes backstrings, numbers, listsan object graph
who picks the typesthe parserthe payload
what runs while parsingnothingconstructors, property setters, OnDeserialized, IObjectReference.GetRealObject, finalizers
worst case on bad inputa parse errorwhatever 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 client

the 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.

a plain gold ring sitting on a dusty mantelpiece

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:

  • ReadToken runs the cookie bytes back through ApplyTransforms in reverse order, then hands the result to BinaryFormatter.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, and SetTransforms is 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 questionwhere the answer lives
what does the sink doopen. SessionSecurityTokenHandler.cs on github
what protects it by defaultopen. deflate plus DPAPI, in DefaultCookieTransforms
can a caller replace thatopen. the constructor overload says yes
what does sharepoint pass in on the federation pathclosed. 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:

Terminal window
$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:

Terminal window
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:

sinknotes
BinaryFormatter.Deserializefull type control. game over on reach.
NetDataContractSerializer.Deserializesame, types travel in the payload
SoapFormatter.Deserializeancient, still shows up
LosFormatter / ObjectStateFormatterviewstate. the toolshell family.
JavaScriptSerializer with SimpleTypeResolverthe resolver is what makes it dangerous
Json.NET with TypeNameHandling != Nonethe most common one in real-world code
DataContractSerializer with a permissive resolverneeds the resolver to be sloppy
XmlSerializer where the type is attacker-influencedclassic sharepoint 2019 territory
DataSet.ReadXml / typed datasetsnested 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:

Terminal window
$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 in web.config that 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 claimwhat it actually assertshow you check ithow it held up here
“the blob is signed, only we can produce it”a signature check runs upstreamfind 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 builtis the binder installed on this instance? read its allow-list, not its existencenothing restricting types on the path
“it’s only called from the internal admin path”reachability is limited by the callerlist every caller, not the one that made you lookfederation sign-in reaches the same code
“the caller already validated the session”auth ran earlier in the pipelinedoes 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 observedwhat you actually have
the sink is reachable from a pre-auth routea path
you control the bytes, but a binder blocks your typea path, and a binder to go read
a benign gadget fires an out-of-band callbacka bug
you sent it and nothing came backa 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.

one does not simply validate an object graph

so when you write one of these up:

recommendationverdict
“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.