Skip to content

TOTP replay protection can be bypassed when delta_time is not divisible by time_interval #261

Description

@AdelBasiony

TOTP replay protection can be bypassed when delta_time is not divisible by time_interval

Summary

For a TOTP user, multiOTP can accept the exact same OTP repeatedly when the user's delta_time is not an exact multiple of time_interval.

The behavior is reproducible with a 60-second TOTP interval:

  • delta_time = 0: replay protection works; the second use is rejected.
  • delta_time = 60, 120, or 180: replay protection works.
  • delta_time = 30 or -30: the same OTP can be accepted repeatedly.

This appears to result from a fractional TOTP step being used during validation, followed by integer truncation when the replay state is stored in last_login.

Affected configuration

Example sanitized user configuration:

algorithm          = totp
time_interval      = 60
number_of_digits   = 6
token_algo_suite   = HMAC-SHA1
delta_time         = -30

The token seed, username, email, unique ID, encryption values, and all other identifying or secret values have been removed.

Expected behavior

After an OTP is successfully used, submitting the exact same OTP again for the same TOTP time step should be rejected with the multiOTP replay error, commonly error code 26 (token already used / same token replayed).

This should not depend on whether delta_time is zero, a positive offset, or a negative offset, provided the configured value represents a valid synchronization offset.

Actual behavior

When delta_time is not divisible by time_interval, the same OTP can be accepted repeatedly. The behavior is deterministic and does not require concurrent requests or credential caching.

Relevant code path

The TOTP authentication path calculates the time step and synchronization step as follows:

$interval        = (0 >= $time_interval) ? 1 : $time_interval;
$now_steps       = intval($now_epoch / $interval);
$last_login_step  = intval($last_login / $interval);
$delta_step      = intval($delta_time) / intval($interval);

For time_interval = 60 and delta_time = -30:

delta_step = -30 / 60 = -0.5

The candidate TOTP counter is then calculated using the fractional value:

$now_steps + $additional_step + $delta_step

It is used both to generate the OTP and to decide whether the token is newer than the last successful token:

$pure_calculated_token = $this->GenerateOathHotp(
    $seed_bin,
    $now_steps + $additional_step + $delta_step,
    $digits,
    $token_algo_suite
);

if (($now_steps + $additional_step + $delta_step) > $last_login_step) {
    // authentication succeeds
}

After success, the calculated step is converted back to a timestamp:

$this->SetUserTokenLastLogin(
    ($now_steps + $additional_step + $delta_step) * $interval
);

SetUserTokenLastLogin() truncates the value to an integer:

$this->_user_data['last_login'] = intval($timestamp);

On the next request, last_login_step is calculated again using integer truncation:

$last_login_step = intval($last_login / $interval);

Why the replay check can pass repeatedly

Assume the current server time corresponds to TOTP step 100:

now_steps   = 100
delta_step  = -0.5
candidate   = 99.5

After successful authentication, the stored timestamp becomes approximately:

last_login = intval(99.5 * 60)
           = 5970

On the next request:

last_login_step = intval(5970 / 60)
                = 99

The replay comparison is then:

candidate 99.5 > last_login_step 99

The same calculated TOTP candidate still passes the > comparison. The state is written again with the same truncated value, so the condition remains true for repeated requests.

The same issue occurs with a positive non-integral offset, for example:

time_interval = 60
delta_time    = 30
delta_step    = 0.5
candidate     = 100.5
last_login    = intval(100.5 * 60) = 6030
last_login_step = intval(6030 / 60) = 100
100.5 > 100

Why exact multiples work

When the synchronization offset is divisible by the interval, delta_step is an integer:

delta_time = 0    -> delta_step = 0

delta_time = 60   -> delta_step = 1

delta_time = 120  -> delta_step = 2

delta_time = 180  -> delta_step = 3

The candidate step and the persisted last_login value remain aligned. On the next request, the replay comparison evaluates to false for the same step, and the request returns the replay error.

Reproduction procedure

  1. Create a software TOTP user with:

    algorithm        = totp
    time_interval    = 60
    number_of_digits = 6
    token_algo_suite = HMAC-SHA1
    
  2. Keep the same token seed and test one delta_time value at a time.

  3. Generate one current OTP.

  4. Authenticate with that OTP.

  5. Submit the exact same OTP again within the same time step.

  6. Record the result and the user's last_login and delta_time values after each request.

Observed test matrix

time_interval delta_time delta_step First use Immediate second use
60 0 0 Accepted Rejected as replay
60 30 0.5 Accepted Accepted repeatedly
60 -30 -0.5 Accepted Accepted repeatedly
60 60 1 Accepted Rejected as replay
60 120 2 Accepted Rejected as replay
60 180 3 Accepted Rejected as replay

The tests were performed sequentially, not concurrently. This makes a race condition an unlikely explanation for this specific behavior.

Scope of the issue

The issue affects the consistency between:

  1. the counter used to generate and validate the TOTP;
  2. the counter used by replay comparison; and
  3. the integer timestamp persisted as last_login.

last_event is not the replay state used by the TOTP branch. It is relevant primarily to event/counter-based token handling such as HOTP. The TOTP branch uses last_login and the derived last_login_step.

Environment

  • MultiOTP: 5.9.8.0
  • Cache level: 0
  • Token type: software TOTP
  • Algorithm: TOTP
  • Token digits: 6
  • Hash algorithm: HMAC-SHA1
  • TOTP interval: 60 seconds

Security impact

A party that obtains a valid TOTP value may be able to reuse it multiple times while the calculated fractional candidate remains greater than the truncated replay state. This weakens the one-time-use property expected from the authentication integration and may permit repeated authentication.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions