Skip to main content

Vulnerability 5 & Bonus: Replay Attack

OWASP A08:2021 — Software and Data Integrity Failures
Severity: Medium — Silent Data Corruption


What Is a Replay Attack?

An attacker intercepts a valid, authenticated request and re-sends it later. Even though the attacker can't read the contents (thanks to encryption) or forge new requests (thanks to authentication), they can still cause damage by replaying a captured operation.

The Classic Scenario

Time 0: Alice uploads "config.txt" version 1 (Attacker captures this)
Time 5: Alice updates "config.txt" to version 2
Time 10: Attacker replays the captured version 1 upload
→ config.txt silently reverts to version 1
→ Alice doesn't know her changes were lost

The attacker didn't need to:

  • Know the file contents
  • Know Alice's password
  • Create a valid session token

They just needed to record and replay a single network packet.

Vulnerable Code

File: vulnerable/src/server/ReplicaNode.java

// VULNERABILITY 5: No replay protection — no nonce, no timestamp.
// The same operation can be replayed indefinitely.

@Override
public String upload(String token, String filename, byte[] data) throws RemoteException {
// No nonce check. No timestamp check.
// If an attacker replays this call, the server processes it again.
saveFile(filename, data);
multicastOperation("UPLOAD", filename);
return "File uploaded successfully";
}

@Override
public String delete(String token, String filename) throws RemoteException {
// Same problem — replayable
deleteFileLocally(filename);
multicastOperation("DELETE", filename);
return "File deleted";
}

Attack Demo

Attacker5_ReplayAttack

cd vulnerable
# (nodes must be running)

java -cp out attacker.Attacker5_ReplayAttack

The attacker's strategy:

// Phase 1: Legitimate operations (Alice working normally)
FileSystemService service = /* connect */;

// Upload v1 (attacker captures this RMI call)
service.upload("alice_token", "config.txt", "Version 1 content".getBytes());
System.out.println("Uploaded v1 — attacker captured this call");

// Update to v2
service.upload("alice_token", "config.txt", "Version 2 — important changes".getBytes());
System.out.println("Uploaded v2 — latest version");

// Verify v2 is there
byte[] v2 = service.download("alice_token", "config.txt");
System.out.println("Current content: " + new String(v2)); // "Version 2 — important changes"

// Phase 2: Attacker replays v1
System.out.println("Attacker replays v1 upload...");
service.upload("alice_token", "config.txt", "Version 1 content".getBytes());

// Verify — v1 has overwritten v2 silently!
byte[] current = service.download("alice_token", "config.txt");
System.out.println("Content after replay: " + new String(current)); // "Version 1 content"
// Alice's changes are GONE — and she has no idea

The Fix — Nonce + Timestamp

Files: util/NonceStore.java and common/FileOperation.java

The NonceStore

public class NonceStore {
// Track seen nonces with their timestamps for cleanup
private final ConcurrentHashMap<String, Long> seenNonces = new ConcurrentHashMap<>();
private static final long WINDOW_MS = 300_000; // 5 minutes
private final ScheduledExecutorService cleaner;

/**
* Validates a write operation against replay.
*
* Rejects if:
* 1. The timestamp is more than 5 minutes old (stale request)
* 2. The nonce has already been used (replay within window)
*/
public synchronized boolean isValid(String nonce, long timestamp) {
// Check 1: Timestamp freshness
long now = System.currentTimeMillis();
if (Math.abs(now - timestamp) > WINDOW_MS) {
return false; // Request too old
}

// Check 2: Nonce uniqueness
if (seenNonces.containsKey(nonce)) {
return false; // Nonce already used — replay detected
}

seenNonces.put(nonce, timestamp);
return true;
}

// Periodic cleanup: remove nonces older than the window
private void startCleaner() {
cleaner.scheduleAtFixedRate(() -> {
long cutoff = System.currentTimeMillis() - WINDOW_MS;
seenNonces.entrySet().removeIf(e -> e.getValue() < cutoff);
}, 1, 1, TimeUnit.MINUTES);
}
}

The FileOperation Changes

Every write operation now carries two extra fields:

public class FileOperation implements Serializable {
// ... existing fields ...

// Timestamp: when this operation was created (enforces freshness)
private final long timestamp;

// Nonce: UUID generated fresh for every operation (ensures uniqueness)
private final String nonce;

// Both auto-populated by the constructor:
private FileOperation(...) {
this.timestamp = System.currentTimeMillis();
this.nonce = UUID.randomUUID().toString();
}
}

Integration in ReplicaNode

// In handleClientOperation(), BEFORE executing any write:
if (op.isWrite()) {
// FIX 5/BONUS: Validate nonce and timestamp
if (!nonceStore.isValid(op.getNonce(), op.getTimestamp())) {
return new OperationResult(false, "REPLAY_REJECTED: Operation is stale or duplicated");
}
}

Why Both Checks Are Needed

AttackTimestamp CheckNonce Check
Replay after 10 minutes✅ Caught (stale)❌ Nonce already cleaned up
Replay after 1 second❌ Still "fresh"✅ Caught (duplicate nonce)
Replay after 4 minutes❌ Still "fresh"✅ Caught (duplicate nonce)
Legitimate fresh request✅ Passes✅ Passes (new nonce)

Each check catches what the other misses:

  • Timestamp catches old replays (outside the 5-minute window)
  • Nonce catches recent replays (within the 5-minute window)

Why 5 Minutes?

private static final long WINDOW_MS = 300_000; // 5 minutes

The window exists because the seenNonces set needs to be periodically cleaned — it can't grow forever. The trade-off:

WindowProsCons
1 minuteSmall memory footprintLegitimate operations age out quickly
5 minutesGood balanceModerate memory use
1 hourCatches very old replaysLarge memory footprint

5 minutes is a practical default. The nonce prevents replays within the window; the timestamp rejects anything older.

Testing the Fix

cd secured
java -cp out test.ReplayRejectionTest

Expected output:

[TEST 1] Fresh upload with new nonce... PASSED
[TEST 2] Replay with same nonce... REJECTED (as expected)
[TEST 3] Upload with 10-minute-old timestamp... REJECTED (as expected)

Next: → Mitigations Summary