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
-
Create a software TOTP user with:
algorithm = totp
time_interval = 60
number_of_digits = 6
token_algo_suite = HMAC-SHA1
-
Keep the same token seed and test one delta_time value at a time.
-
Generate one current OTP.
-
Authenticate with that OTP.
-
Submit the exact same OTP again within the same time step.
-
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:
- the counter used to generate and validate the TOTP;
- the counter used by replay comparison; and
- 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.
TOTP replay protection can be bypassed when
delta_timeis not divisible bytime_intervalSummary
For a TOTP user, multiOTP can accept the exact same OTP repeatedly when the user's
delta_timeis not an exact multiple oftime_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, or180: replay protection works.delta_time = 30or-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:
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_timeis zero, a positive offset, or a negative offset, provided the configured value represents a valid synchronization offset.Actual behavior
When
delta_timeis not divisible bytime_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:
For
time_interval = 60anddelta_time = -30:The candidate TOTP counter is then calculated using the fractional value:
It is used both to generate the OTP and to decide whether the token is newer than the last successful token:
After success, the calculated step is converted back to a timestamp:
SetUserTokenLastLogin()truncates the value to an integer:On the next request,
last_login_stepis calculated again using integer truncation:Why the replay check can pass repeatedly
Assume the current server time corresponds to TOTP step
100:After successful authentication, the stored timestamp becomes approximately:
On the next request:
The replay comparison is then:
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:
Why exact multiples work
When the synchronization offset is divisible by the interval,
delta_stepis an integer:The candidate step and the persisted
last_loginvalue 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
Create a software TOTP user with:
Keep the same token seed and test one
delta_timevalue at a time.Generate one current OTP.
Authenticate with that OTP.
Submit the exact same OTP again within the same time step.
Record the result and the user's
last_loginanddelta_timevalues after each request.Observed test matrix
time_intervaldelta_timedelta_stepThe 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:
last_login.last_eventis 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 useslast_loginand the derivedlast_login_step.Environment
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.