VDB
Sign up
MEDIUM6.8

GHSA-274f-6w77-8qm9

@sync-in/server vulnerable to TOTP Brute-Force via `POST /api/app/sync/register`

Quick fix

GHSA-274f-6w77-8qm9 — @sync-in/server: upgrade to the fixed version with the command below.

npm install @sync-in/server@2.4.0

Details

**Affected component:** Sync-in Server v2.3.0, `POST /api/app/sync/register`.

**Required attacker capability:** Valid login and password for a TOTP-enabled account with desktop sync permission.

## Summary

`POST /api/app/sync/register` accepts credentials and a TOTP code to register a desktop sync client. In the vulnerable version, on a failed TOTP attempt, `SyncClientsManager.register()` called `updateAccesses(user, ip, false)`, which hit a freeze branch that wrote `passwordAttempts` back unchanged. The counter never reached `USER_MAX_PASSWORD_ATTEMPTS` (10), so the account lockout gate never fired for repeated TOTP failures through this endpoint.

A successful TOTP guess registers a sync client and returns a `{clientId, clientToken}` pair, provided the account has the required desktop app permission and the registration payload is valid. The token can then be exchanged via `POST /api/app/sync/auth/cookie` for an authenticated session. While the guessed TOTP code is still valid, and because the attacker already knows the password, the attacker can also call `POST /api/auth/2fa/disable` to remove MFA.

## Details

The endpoint is declared at `sync.controller.ts` line 71. `@AuthTokenSkip()` bypasses the bearer-token guard, so the route is reachable without any prior session: ```typescript @Post(SYNC_ROUTE.REGISTER) @AuthTokenSkip() register(@Body() syncClientRegistrationDto: SyncClientRegistrationDto, @Req() req: FastifyRequest): Promise<SyncClientAuthRegistration> { return this.syncClientsManager.register(syncClientRegistrationDto, req.ip) } ``` Inside `SyncClientsManager.register()`, after both the TOTP code and the recovery code are rejected, the handler fires a fire-and-forget access update and throws (`sync-clients-manager.service.ts` line 73): ```typescript this.usersManager.updateAccesses(user, ip, false).catch((e: Error) => this.logger.error({ tag: this.register.name, msg: `${e}` })) throw new HttpException(authCode.message, HttpStatus.UNAUTHORIZED) ```

In the vulnerable version, `updateAccesses()` at `users-manager.service.ts` line 182 defaulted `isAuthTwoFa` to `false`:

```typescript async updateAccesses(user: UserModel, ip: string, success: boolean, isAuthTwoFa = false) { let passwordAttempts: number if (!isAuthTwoFa && configuration.auth.mfa.totp.enabled && user.twoFaEnabled) { passwordAttempts = user.passwordAttempts } else { passwordAttempts = success ? 0 : Math.min(user.passwordAttempts + 1, USER_MAX_PASSWORD_ATTEMPTS) } await this.usersQueries.updateUserOrGuest(user.id, { ... passwordAttempts: passwordAttempts, isActive: user.isActive && passwordAttempts < USER_MAX_PASSWORD_ATTEMPTS }) } ```

When `register()` called `updateAccesses(user, ip, false)`, `isAuthTwoFa` defaulted to `false`. The condition on line 184 evaluated to `true` when TOTP was enabled site-wide and the account had it active. The `else` branch with `Math.min(user.passwordAttempts + 1, ...)` was never reached. `passwordAttempts` was written back unchanged, and the lockout gate in `validateUserAccess()` at line 89 never fired for repeated TOTP failures through this endpoint.

The freeze was designed for the web login flow, where a correct password at `POST /api/auth/login` produces a partial session and the counter should be preserved until `POST /api/auth/2fa/login/verify` completes. That route calls `authProvider2FA.verify(body, req, true)`, which passes `isAuthTwoFa=true` into `updateAccesses()` and correctly increments on 2FA failure. The `register()` endpoint reused `updateAccesses()` for an outright TOTP rejection while passing the default `isAuthTwoFa=false`, triggering the freeze incorrectly.

The same freeze also applied to `logUser()` (`users-manager.service.ts` line 69), called by both `POST /api/auth/login` and `POST /api/auth/token`. On a wrong password for a 2FA-enabled account, `updateAccesses(user, ip, false)` was called without an `isAuthTwoFa` argument, so the freeze fired and `passwordAttempts` was preserved rather than incremented.

## PoC

First, create a test account with TOTP MFA enabled and desktop sync permission. Then run the following:

[poc_totp_bruteforce.py](https://github.com/user-attachments/files/28857878/poc_totp_bruteforce.py)

```bash $ python3 poc_totp_bruteforce.py --url http://192.168.16.132:8080 --user mfatest --password 'Str0ngP@ss99!' --concurrency 4 --batch 100 ```

Example output:

```text [*] Target : http://192.168.16.132:8080 [*] Account : mfatest [*] Concurrency : 4

[*] Step 1: Confirming credentials and 2FA status... [+] Credentials valid, 2FA active.

[*] Step 2: Brute-forcing TOTP codes (4 workers)... Ranges: W0=000000-250000, W1=250000-500000, W2=500000-750000, W3=750000-1000000 [W2] 1,100 total | 11.3 req/s | retries: 0 <snip> [W1] 195,400 total | 11.0 req/s | retries: 0

[*] Step 3: Results Total attempts : 195,907 Time elapsed : 17769.5s (296.2min) Average RPS : 11.0

[+] VALID TOTP CODE FOUND : 026961 [+] clientId : 13951b88-03d4-4854-8a29-cd8921d73d82 [+] clientToken : c92ef5ca-7432-44d0-8a94-56398bfe4117

[*] Step 4: Confirming access and attempting to disable 2FA... [+] Authenticated as : mfatest (id=3, role=1) passwordAttempts : 0 [+] 2FA DISABLED. Account 'mfatest' now accessible with password alone. ```

**Measured observations:**

- No account lockout was observed across 195,907 failed TOTP attempts in this test against the vulnerable version. - The `clientToken` was exchanged for an authenticated session. - MFA was disabled via `POST /api/auth/2fa/disable` while the guessed TOTP code was still valid and because the attacker already knew the account password.

## Impact

An attacker who already knows valid credentials for a TOTP-enabled account with desktop sync permission can brute-force the second factor through `POST /api/app/sync/register` without triggering account lockout.

With `drift: 1`, 3 of 1,000,000 six-digit codes are valid per 30-second window (`p = 3/1,000,000`), giving an expected 333,333 attempts to find a valid code.

At 3 r/s, measured against a default single-worker deployment:

| Success probability | Attempts | Time at 3 r/s | |---|---:|---:| | 50% | 231,049 | 21.4 h | | 90% | 767,528 | 71.1 h | | 95% | 998,577 | 92.5 h | | 99% | 1,535,056 | 142.1 h | | Expected (mean) | 333,333 | 30.9 h |

Deployments with `server.workers > 1` may allow higher throughput, depending on CPU capacity and other bottlenecks. Throughput is heavily influenced by server-side password verification cost, worker count, database latency, and deployment limits, not only by the attacker's network speed.

## Remediation

Add `&& success` to the freeze condition at `users-manager.service.ts` line 184:

```typescript // Before if (!isAuthTwoFa && configuration.auth.mfa.totp.enabled && user.twoFaEnabled) {

// After if (!isAuthTwoFa && configuration.auth.mfa.totp.enabled && user.twoFaEnabled && success) { ```

The freeze still applies when a password succeeds but 2FA is pending, which was its intended purpose. A failed TOTP at `register()` and a failed password at `login`/`token` both fall through to the increment path, restoring lockout after 10 failures.

For defense-in-depth, apply an IP and/or account-based rate limiter to `POST /api/app/sync/register` and other pre-auth credential endpoints.

Are you affected?

Enter the version of the package you're using.

Affected packages

npm/@sync-in/server
Introduced in: 0Fixed in: 2.4.0
Fixnpm install @sync-in/server@2.4.0

References