hacktricks/src/pentesting-web/deserialization/java-signedobject-gated-deserialization.md
HackTricks News Bot 7b7708bb6b Add content from: Is This Bad? This Feels Bad — GoAnywhere CVE-2025-10035
- Remove searchindex.js (auto-generated file)
2025-09-25 18:41:33 +00:00

7.8 KiB
Raw Blame History

Java SignedObject-gated Deserialization and Pre-auth Reachability via Error Paths

{{#include ../../banners/hacktricks-training.md}}

This page documents a common "guarded" Java deserialization pattern built around java.security.SignedObject and how seemingly unreachable sinks can become pre-auth reachable via error-handling flows. The technique was observed in Fortra GoAnywhere MFT (CVE-2025-10035) but is applicable to similar designs.

Threat model

  • Attacker can reach an HTTP endpoint that eventually processes an attacker-supplied byte[] intended to be a serialized SignedObject.
  • The code uses a validating wrapper (e.g., Apache Commons IO ValidatingObjectInputStream or a custom adapter) to constrain the outermost type to SignedObject (or byte[]).
  • The inner object returned by SignedObject.getObject() is where gadget chains can trigger (e.g., CommonsBeanutils1), but only after a signature verification gate.

Typical vulnerable pattern

A simplified example based on com.linoma.license.gen2.BundleWorker.verify:

private static byte[] verify(byte[] payload, KeyConfig keyCfg) throws Exception {
    String sigAlg = "SHA1withDSA";
    if ("2".equals(keyCfg.getVersion())) {
        sigAlg = "SHA512withRSA";        // key version controls algorithm
    }
    PublicKey pub = getPublicKey(keyCfg);
    Signature sig = Signature.getInstance(sigAlg);

    // 1) Outer, "guarded" deserialization restricted to SignedObject
    SignedObject so = (SignedObject) JavaSerializationUtilities.deserialize(
        payload, SignedObject.class, new Class[]{ byte[].class });

    if (keyCfg.isServer()) {
        // Hardened server path
        return ((SignedContainer) JavaSerializationUtilities.deserializeUntrustedSignedObject(
            so, SignedContainer.class, new Class[]{ byte[].class }
        )).getData();
    } else {
        // 2) Signature check using a baked-in public key
        if (!so.verify(pub, sig)) {
            throw new IOException("Unable to verify signature!");
        }
        // 3) Inner object deserialization (potential gadget execution)
        SignedContainer inner = (SignedContainer) so.getObject();
        return inner.getData();
    }
}

Key observations:

  • The validating deserializer at (1) blocks arbitrary top-level gadget classes; only SignedObject (or raw byte[]) is accepted.
  • The RCE primitive would be in the inner object materialized by SignedObject.getObject() at (3).
  • A signature gate at (2) enforces that the SignedObject must verify against a product-baked public key. Unless the attacker can produce a valid signature, the inner gadget never deserializes.

Exploitation considerations

To achieve code execution, an attacker must deliver a correctly signed SignedObject that wraps a malicious gadget chain as its inner object. This generally requires one of the following:

  • Private key compromise: obtain the matching private key used by the product to sign/verify license objects.
  • Signing oracle: coerce the vendor or a trusted signing service to sign attacker-controlled serialized content (e.g., if a license server signs an embedded arbitrary object from client input).
  • Alternate reachable path: find a server-side path that deserializes the inner object without enforcing verify(), or that skips signature checks under a specific mode.

Absent one of these, signature verification will prevent exploitation despite the presence of a deserialization sink.

Pre-auth reachability via error-handling flows

Even when a deserialization endpoint appears to require authentication or a session-bound token, error-handling code can inadvertently mint and attach the token to an unauthenticated session.

Example reachability chain (GoAnywhere MFT):

  • Target servlet: /goanywhere/lic/accept/ requires a session-bound license request token.
  • Error path: hitting /goanywhere/license/Unlicensed.xhtml with trailing junk and invalid JSF state triggers AdminErrorHandlerServlet, which does:
    • SessionUtilities.generateLicenseRequestToken(session)
    • Redirects to vendor license server with a signed license request in bundle=<...>
  • The bundle can be decrypted offline (hard-coded keys) to recover the GUID. Keep the same session cookie and POST to /goanywhere/lic/accept/ with attacker-controlled bundle bytes, reaching the SignedObject sink pre-auth.

Proof-of-reachability (impact-less) probe:

GET /goanywhere/license/Unlicensed.xhtml/x?javax.faces.ViewState=x&GARequestAction=activate HTTP/1.1
Host: <target>
  • Unpatched: 302 Location header to https://my.goanywhere.com/lic/request?bundle=... and Set-Cookie: ASESSIONID=...
  • Patched: redirect without bundle (no token generation).

Blue-team detection

Indicators in stack traces/logs strongly suggest attempts to hit a SignedObject-gated sink:

java.io.ObjectInputStream.readObject
java.security.SignedObject.getObject
com.linoma.license.gen2.BundleWorker.verify
com.linoma.license.gen2.BundleWorker.unbundle
com.linoma.license.gen2.LicenseController.getResponse
com.linoma.license.gen2.LicenseAPI.getResponse
com.linoma.ga.ui.admin.servlet.LicenseResponseServlet.doPost

Hardening guidance

  • Maintain signature verification before any getObject() call and ensure the verification uses the intended public key/algorithm.
  • Replace direct SignedObject.getObject() calls with a hardened wrapper that re-applies filtering to the inner stream (e.g., deserializeUntrustedSignedObject using ValidatingObjectInputStream/ObjectInputFilter allow-lists).
  • Remove error-handler flows that issue session-bound tokens for unauthenticated users. Treat error paths as attack surface.
  • Prefer Java serialization filters (JEP 290) with strict allow-lists for both outer and inner deserializations. Example:
ObjectInputFilter filter = info -> {
    Class<?> c = info.serialClass();
    if (c == null) return ObjectInputFilter.Status.UNDECIDED;
    if (c == java.security.SignedObject.class || c == byte[].class) return ObjectInputFilter.Status.ALLOWED;
    return ObjectInputFilter.Status.REJECTED; // outer layer
};
ObjectInputFilter.Config.setSerialFilter(filter);
// For the inner object, apply a separate strict DTO allow-list

Example attack chain recap (CVE-2025-10035)

  1. Pre-auth token minting via error handler:
GET /goanywhere/license/Unlicensed.xhtml/watchTowr?javax.faces.ViewState=watchTowr&GARequestAction=activate

Receive 302 with bundle=... and ASESSIONID=...; decrypt bundle offline to recover GUID.

  1. Reach the sink pre-auth with same cookie:
POST /goanywhere/lic/accept/<GUID> HTTP/1.1
Cookie: ASESSIONID=<value>
Content-Type: application/x-www-form-urlencoded

bundle=<attacker-controlled-bytes>
  1. RCE requires a correctly signed SignedObject wrapping a gadget chain. Researchers could not bypass signature verification; exploitation hinges on access to a matching private key or a signing oracle.

Fixed versions and behavioural changes

  • GoAnywhere MFT 7.8.4 and Sustain Release 7.6.3:
    • Harden inner deserialization by replacing SignedObject.getObject() with a wrapper (deserializeUntrustedSignedObject).
    • Remove error-handler token generation, closing pre-auth reachability.

Notes on JSF/ViewState

The reachability trick leverages a JSF page (.xhtml) and invalid javax.faces.ViewState to route into a privileged error handler. While not a JSF deserialization issue, its a recurring pre-auth pattern: break into error handlers that perform privileged actions and set security-relevant session attributes.

References

{{#include ../../banners/hacktricks-training.md}}