---
title: "The Clock That Cracks Passwords"
url: https://www.exakat.io/the-clock-that-cracks-passwords/
date: 2026-09-01
modified: 2026-09-01
author: "dams"
description: "The Clock That Cracks Passwords To understand what is the clock that cracks passwords, let's start with a quizz. Which security flaw does the following PHP login check contains? [php]..."
categories:
  - "Code auditing"
tags:
  - "php"
  - "security"
  - "timing"
image: https://www.exakat.io/wp-content/uploads/2026/09/thread.320.jpg
word_count: 2620
---

# The Clock That Cracks Passwords

# The Clock That Cracks Passwords

To understand what is the clock that cracks passwords, let's start with a quizz. Which security flaw does the following PHP login check contains?

If you said "the password should be hashed with bcrypt", you are right and you may stay. If you said "use `===` instead", you are wrong and we will get to why later. If you stared at it for thirty seconds and saw nothing, you are in the majority. The bug passes a code review, satisfies a static analyser, and returns the correct answer every single time.

It just returns that answer at slightly different speeds, depending on how close the guess was.

The bug is `==`. Not because it lies. Because it talks too much.

## The Lie Inside ==

PHP compares strings character by character, left to right, and stops the moment it finds a mismatch. This is the efficient method: no need to continue comparing once a mismatch is found. For ordinary work, it is fine. For secrets, it is a slow-motion catastrophe.

Compare the three most common ways to check whether two strings are equal:

`hash_equals()` arrived in PHP 5.6, introduced specifically for this problem. It compares two strings and returns `true` or `false`, but it never stops early. Whether the very first byte mismatches or only the very last byte mismatches, the function takes the same amount of time. It has a poker face.

`===` is often presented as the safe upgrade from `==`. It is a safer operator in general: it avoids the type-coercion surprises that trip up many PHP developers. But for secret comparison it is no better. It does the type check, then walks the bytes and exits at the first difference. Two distinct problems, regularly confused.

The insight at the heart of this post is a simple one: the *duration* of a comparison is information. And in security, information is currency.

## Cracking a Password, One Nanosecond at a Time

Here is the attack, stated as plainly as possible.

A server checks whether `$guess == $secret`. The secret is `supersecret123`, fourteen characters. The attacker does not know the secret. The attacker does know that the server is using an early-exit comparison, and that the server can be queried repeatedly.

So the attacker observes:

- `xxxxxxxxxxxxxx` is rejected very quickly. The `x` mismatches at position 0 and the comparison halts.
- `sxxxxxxxxxxxxx` takes slightly longer. The `s` matches position 0. The `x` mismatches at position 1 and only then does the comparison halt.
- `suxxxxxxxxxxxx` takes slightly longer still. Two matching characters, two loop iterations before exit.

Each extra matching prefix byte adds one more step. Each step takes a few nanoseconds. The attacker measures those nanoseconds and reads the story they tell.

The algorithm that follows is almost embarrassingly simple:

- Fix position 0. Try every character in the charset. Keep the one that produces the *slowest rejection*. That is the correct first character.
- Fix that character. Move to position 1. Try every character. Keep the slowest.
- Repeat until the response says "access granted" instead of "access denied".

A small digression on the word "oracle": in cryptography, an oracle is any system that answers yes-or-no questions about a secret. The word is borrowed from antiquity. The oracle at Delphi was famous for appearing to say very little while actually saying quite a lot. The resemblance is apt.

The mathematical payoff of this oracle is dramatic. For a fourteen-character password drawn from lowercase letters and digits, a brute-force search requires at most 36^14 guesses, which is roughly 4.8 × 10²¹. That number is approximately the number of grains of sand on Earth, multiplied by ten. The timing oracle reduces the search to at most 36 × 14 guesses: 504. The difference is not a small improvement. It is the difference between impossible and before lunch.

The `attack.php` script from this post's repository makes the oracle visible:

The bar chart in the first block is the oracle rendered in ASCII. The flat line in the second block is what security looks like.

## The Guessing Game

Reading about an attack is one thing. Actually holding the oracle and using it is another.

The repository includes `game.php`, a small CLI script that generates a random password and invites you to crack it using timing alone. No other hints are given.

In interactive mode, the script prints how long each guess took and nothing else. You use those nanoseconds to converge on the answer. There is no scoreboard, but you will feel the moment you start using the oracle deliberately rather than guessing at random. That is the point of the exercise.

The `--auto` mode is the more instructive one. It runs the byte-by-byte attack in front of you, printing the timing for each position as the solver locks in one character at a time:

Watching the solver converge is the most convincing argument for `hash_equals()`. No amount of prose is as persuasive as watching a six-character password dissolve in under two thousand guesses.

## The Framework Illusion

At this point a reasonable developer might push back. The demo ran each comparison 150,000 times in a tight inner loop. A real server routes the request through middleware, reads from a database, renders a template, writes a log line. The nanosecond signal is buried under fifty milliseconds of framework overhead. Surely this is theoretical?

It is not theoretical. It is slightly more expensive.

The signal-to-noise ratio is genuinely unfavourable. A typical Symfony or Laravel response takes 50 to 200 milliseconds in production. The per-byte timing difference we measured is 50 to 200 nanoseconds. That is a ratio of about one in a million.

But the attacker is not sending one request per candidate. The attacker sends ten thousand requests per candidate and computes the median response time. Framework overhead is random noise: it varies independently of the secret. The per-byte timing difference is a systematic bias: it shifts the median upward, consistently, for the correct character. Statistics are patient. Given enough samples, the signal emerges from the noise with mathematical certainty.

A historical note: the 2023 Marvin attack against RSA decryption recovered private keys over a standard TCP connection, exploiting timing differences measured in milliseconds, not nanoseconds. TLS-level timing attacks have worked against encrypted traffic. The Lucky Thirteen attack on TLS 1.2 broke the CBC padding scheme using remote timing differences on the order of a few hundred microseconds. The argument that "the noise drowns it out" has been disproved so many times that it no longer deserves to be a comfort.

The framework raises the cost of the attack. It does not close the oracle.

## Time-Padding: Hiding the Clock

Replacing `==` with `hash_equals()` fixes the comparison. It does not fix the clock.

Even with a constant-time comparison, the total response time can leak. A login endpoint that returns in 2 ms when the username is unknown, because it short-circuits before ever reaching the password check, and in 15 ms when the username exists, because it fetches the user record and computes the full comparison, is leaking account existence. That is a different oracle, and it does not touch the password at all.

The fix is to floor every auth response to a minimum duration and add a small random jitter:

Wrap the entire authentication flow in one call. "User not found" and "wrong password" both take the same visible time. The jitter prevents a determined attacker from cancelling out the floor by averaging it away.

Two things to keep in mind. First, this is defence in depth, not a replacement for `hash_equals()`. Fix the comparison and pad the response: both, not one or the other. Second, set the minimum generously. If your slowest legitimate auth path takes 80 ms on a slow day, a 100 ms floor leaves no room. Make it 300 ms. Users will not notice. The attack becomes several orders of magnitude more expensive.

## The Rogues' Gallery

`hash_equals()` covers `==`. But `==` is not the only comparison function in PHP, and the others are equally talkative.

| Expression or function | Early exit | Safer alternative |
| ---------------------- | ---------- | ----------------- |
| `$a == $b` | yes | `hash_equals()` |
| `$a === $b` | yes | `hash_equals()` |
| `strcmp($a, $b)` | yes | `hash_equals()` |
| `strncmp($a, $b, $n)` | yes | `hash_equals()` |
| `in_array($token, $list)` | yes | compare hashes of all entries |
| `array_search($token, $list)` | yes | compare hashes |
| `str_contains($s, $needle)` | yes | HMAC-based membership check |
| `strpos($s, $needle)` | yes | HMAC-based membership check |

`in_array` is the one that surprises developers the most. It is common to keep a list of valid API tokens and check incoming requests against it. The call iterates the list and exits the moment it finds a match, or exhausts the list and returns false. The timing tells the attacker both whether the token is valid and, if not, how far alphabetically the closest token is. The safe pattern is to hash every candidate in the list and compare hashes: the hashes are not secret, and `hash_equals()` on two hashes gives away nothing.

`str_contains` and `strpos` are worth naming because they appear in security code more often than they should. A pattern like `if (str_contains($apiKey, $knownPrefix))` leaks how far the prefix match extends. That information feeds the same oracle.

## It Is Not Just Passwords

We have been talking about passwords. The same clock ticks on anything the application keeps secret.

Password-reset tokens are the highest-risk example after passwords themselves. They are typically random hex strings, stored in the database, compared when a user follows a reset link. A timing oracle on the reset endpoint lets an attacker forge a valid token without ever receiving the email. The fix is `hash_equals()` on the comparison, a hashed store in the database, and a short expiry window so the oracle has less time to be useful.

Email addresses are a subtler case. They are rarely treated as secrets, because users hand them out freely. But a login form that returns faster when an email address is unknown than when it is known is an account-enumeration oracle. The attacker does not need to guess passwords at all. She just needs to know which emails are registered, which is often enough to cause harm. Breached credential lists are built this way. The time-padding approach from the previous section handles this, since both "user not found" and "wrong password" end up taking the same visible time.

API keys and webhook secrets are long-lived, machine-generated strings. They are the most valuable timing target in a typical web application, because a compromised API key grants ongoing access without triggering any password-reset flow, and the key owner may not notice for months. Constant-time comparison on ingest, a generous time pad on the validation endpoint.

Session tokens sit in cookies and are compared against the session store on every request. Most frameworks handle this correctly in their default session handlers. Custom session handlers, or hand-rolled session logic, are where the problem tends to reappear.

The question to ask about any endpoint that receives a secret value from the outside is: does the total response time, as seen by the caller, correlate with how close the input was to the correct answer? If yes, you have an oracle.

## When the Secret Hits the Database

All of the above assumes the comparison lives in PHP. But many of these values arrive via a database query first, and the database has its own clock.

Consider the classic token-verification pattern:

If the `token` column has a B-tree index, the database engine walks the index tree to find the row. The depth of that walk depends on where the sought value sits relative to the existing rows. Tokens that sort close to existing values in the index may require slightly different numbers of page accesses than tokens that sort far from any existing row. This is a fainter signal than PHP-level early exit, but it is the same category of leak: the server's work varies with the input value.

The safe pattern is straightforward: never store raw secrets in the database. Store a SHA-256 hash of the token, and verify by hashing the incoming value and looking up the hash.

Notice that `hash_equals()` appears even here, comparing two hashes. SHA-256 hashes are not secret in the same way the raw token is, so leaking comparison timing on the hash does not directly reveal the token. But the habit costs nothing and removes the question entirely.

Passwords follow a different pattern, because you cannot verify a bcrypt password in SQL at all: the stored hash encodes a random salt and the bcrypt cost factor, and the comparison requires running the full bcrypt computation on the input. The only correct flow is to fetch the user row by email, then call `password_verify()` in PHP. `password_verify()` is constant-time by design and handles the hash format automatically. The database never sees the comparison; it only handles the email lookup.

ORM frameworks do not change any of this. Eloquent, Doctrine, and their equivalents generate the same `WHERE token = ?` query under the hood. They are code-generation tools, not security layers. It is not a criticism: it is a reminder that switching ORMs does not close the oracle.

## A Checklist for Monday Morning

The journey from a single `==` covered rather more ground than expected. Here is the condensed version.

- Replace `==`, `===`, and `strcmp` on any secret value with `hash_equals()`
- Add a time pad with random jitter to every authentication endpoint
- Audit every `in_array` and `array_search` call that touches API tokens, session identifiers, or other long-lived credentials
- Store tokens as SHA-256 hashes in the database; never store raw secrets
- Use `password_hash()` and `password_verify()` for passwords; never do password comparison in SQL
- Check username and email lookups for account-enumeration timing side-channels

The attack started with a single two-character operator. The fix also starts there: one function call, `hash_equals()`. Everything else on the list is the same principle applied consistently, widened to cover the full surface.

The invariant to maintain is simple: the time the server spends on a request must not reveal how close the caller's guess was. When that holds, the oracle goes silent, and a before-lunch problem becomes 4.8 × 10²¹ guesses again.