Back to Blog
critical SEVERITY7 min read

How OAuth 2.0 CSRF happens in PHP and how to fix it

A critical OAuth 2.0 CSRF vulnerability in `login_weibo.php` allowed attackers to forge Weibo login requests by exploiting the missing `state` parameter validation. Without this check, an attacker could trick a victim's browser into completing an OAuth flow with the attacker's authorization code, potentially hijacking the victim's session. The fix generates a cryptographically random state token, stores it in the session, and validates it on callback.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is an OAuth 2.0 CSRF vulnerability (CWE-352) in PHP's `login_weibo.php`, where the authorization callback accepted the `code` GET parameter without validating a `state` token, violating RFC 6749 §10.12. The fix generates a 16-byte cryptographically random state value using `bin2hex(random_bytes(16))`, stores it in `$_SESSION['oauth_weibo_state']`, appends it to the Weibo authorization URL, and verifies it on callback before processing the authorization code.

Vulnerability at a Glance

cweCWE-352
fixGenerate a random state token per authorization request, store in session, and validate on callback before exchanging the code
riskAttacker can force a victim to log in as the attacker, enabling account takeover or session fixation
languagePHP
root causeThe OAuth callback in login_weibo.php processed `$_GET['code']` without verifying a session-bound state token
vulnerabilityOAuth 2.0 CSRF (Missing State Parameter)

How OAuth 2.0 CSRF Happens in PHP and How to Fix It

The Incident

The login_weibo.php file in this Online Judge platform handles Weibo OAuth 2.0 login — a common social login flow where users authorize the app via Weibo's authorization server and are redirected back with an authorization code. But a critical flaw on line 22 meant that any code value arriving via a GET request would be blindly accepted and exchanged for an access token, with no verification that the request originated from a legitimate flow initiated by the actual user.

This is a textbook OAuth 2.0 CSRF vulnerability, and it's more dangerous than it sounds.


The Vulnerability Explained

What Was Missing

OAuth 2.0's RFC 6749 (§10.12) explicitly requires the use of a state parameter to prevent CSRF. The state value is:
1. Generated by the client before redirecting the user to the authorization server
2. Stored in the user's session
3. Sent along with the authorization request
4. Returned by the authorization server in the callback
5. Validated by the client before proceeding

In the vulnerable version of login_weibo.php, steps 1–5 were entirely absent. Here's the original callback handler:

// VULNERABLE CODE (before fix)
if (isset($_GET['code'])) {
    $code = $_GET['code'];
    $GURL = "https://api.weibo.com/oauth2/access_token?";
    $vars = array(
        // ... token exchange parameters
    );
    // Immediately exchanges code for token — no origin check!

And the authorization redirect:

// VULNERABLE CODE (before fix)
} else {
    $CBURL = "https://api.weibo.com/oauth2/authorize?client_id={$OJ_WEIBO_AKEY}&response_type=code&redirect_uri=$OJ_WEIBO_CBURL";
    header("Location: " . $CBURL);
}

Notice that no state parameter is generated or appended to the Weibo authorization URL, and the callback performs zero session-bound validation.

The Attack Scenario

Here's exactly how an attacker would exploit this against a user of this Online Judge platform:

  1. Attacker initiates their own OAuth flow against the target OJ instance, obtaining a valid Weibo authorization code (ATTACKER_CODE) linked to their Weibo account.

  2. Attacker crafts a malicious page containing a hidden request to the callback URL:
    html <img src="https://target-oj.com/login_weibo.php?code=ATTACKER_CODE" style="display:none">

  3. Victim visits the malicious page while logged out of the OJ (or with an active session). The victim's browser silently fires a GET request to login_weibo.php?code=ATTACKER_CODE.

  4. The server exchanges ATTACKER_CODE for an access token tied to the attacker's Weibo account and logs the victim in as the attacker.

  5. Result: The victim is now authenticated as the attacker. Any actions the victim takes — submitting solutions, viewing private data — are performed under the attacker's identity. Conversely, the attacker can monitor what the victim does if session state is shared.

This is a 2-step exploit chain: craft the malicious page, deliver it to the victim. No phishing of credentials required.

Real-World Impact for This Application

This is a competitive programming / Online Judge platform. Exploitation could allow:
- Account takeover by association: Force a victim to log in as the attacker, allowing the attacker to submit solutions under the victim's contest account
- Session fixation: Depending on session handling, the attacker may be able to predict or share session state
- Trust abuse: Any contest submissions, rankings, or private problem sets the victim accesses would be attributed to or visible by the attacker's account


The Fix

The fix implements the complete OAuth 2.0 state parameter flow in two places.

Step 1: Generate and Store State on Authorization

// FIXED: Authorization redirect
} else {
    $state = bin2hex(random_bytes(16));          // 32-char hex, 128 bits of entropy
    $_SESSION['oauth_weibo_state'] = $state;     // Bind to user's session
    $CBURL = "https://api.weibo.com/oauth2/authorize?client_id={$OJ_WEIBO_AKEY}&response_type=code&redirect_uri=$OJ_WEIBO_CBURL&state=" . urlencode($state);
    header("Location: " . $CBURL);
}

random_bytes(16) generates 16 cryptographically secure random bytes (128 bits of entropy), which bin2hex() encodes as a 32-character hex string. This value is stored in $_SESSION['oauth_weibo_state'] and appended to the Weibo authorization URL. Weibo will echo this value back in the callback.

Step 2: Validate State Before Processing the Code

// FIXED: Callback handler
if (isset($_GET['code'])) {
    if (!isset($_GET['state']) || 
        !isset($_SESSION['oauth_weibo_state']) || 
        $_GET['state'] !== $_SESSION['oauth_weibo_state']) {
        echo "Invalid state parameter!";
        exit;
    }
    unset($_SESSION['oauth_weibo_state']);   // Consume the token — prevents replay
    $code = $_GET['code'];
    // ... proceed with token exchange

The validation checks three conditions:
1. $_GET['state'] must be present in the callback
2. $_SESSION['oauth_weibo_state'] must exist (i.e., a legitimate flow was started)
3. The two values must match exactly (strict !== comparison)

After validation, unset($_SESSION['oauth_weibo_state']) removes the token so it cannot be replayed in a second request.

Before/After Comparison

Aspect Before After
State generated? ❌ No bin2hex(random_bytes(16))
State in auth URL? ❌ No &state=... appended
State stored in session? ❌ No $_SESSION['oauth_weibo_state']
Callback validates state? ❌ No ✅ Three-condition check
Token consumed after use? ❌ N/A unset($_SESSION['oauth_weibo_state'])

Prevention & Best Practices

Always Implement the Full OAuth 2.0 State Parameter Flow

The state parameter is not optional — RFC 6749 §10.12 states it "SHOULD" be used (and in practice, for any web application, it MUST be). Follow this pattern for every OAuth 2.0 integration:

// On initiating the OAuth flow:
$state = bin2hex(random_bytes(16));
$_SESSION['oauth_state'] = $state;
$authUrl = $provider->getAuthorizationUrl(['state' => $state]);
header('Location: ' . $authUrl);

// On receiving the callback:
if (empty($_GET['state']) || 
    empty($_SESSION['oauth_state']) || 
    $_GET['state'] !== $_SESSION['oauth_state']) {
    throw new \RuntimeException('Invalid state parameter');
}
unset($_SESSION['oauth_state']);
// Now safe to exchange code

Use a Battle-Tested OAuth Library

Hand-rolling OAuth flows is error-prone. PHP libraries like league/oauth2-client handle state generation and validation automatically.

Validate Strictly — Don't Use Loose Comparisons

Note the fix uses !== (strict inequality) rather than !=. In PHP, loose comparisons can be exploited with type juggling. Always use strict comparisons for security tokens.

Session Must Be Active Before OAuth Initiation

The state token is only as secure as the session storing it. Ensure session_start() is called before the OAuth flow begins, and that sessions use secure, HttpOnly cookies.

Detection with Static Analysis

  • Semgrep: Write a rule matching $_GET['code'] without an adjacent $_GET['state'] comparison
  • OWASP ASVS: V3.5.3 requires CSRF tokens for state-changing operations; OAuth state serves this purpose
  • CWE-352: Cross-Site Request Forgery — the canonical classification for this issue

Key Takeaways

  • login_weibo.php accepted $_GET['code'] with zero origin verification — any page on the internet could trigger a Weibo login as any Weibo user
  • The state parameter in OAuth 2.0 is a CSRF token — treat it with the same rigor as form CSRF tokens: generate cryptographically, store server-side, validate strictly, consume after use
  • bin2hex(random_bytes(16)) is the correct PHP idiom for generating unpredictable tokens — never use rand(), mt_rand(), or uniqid() for security tokens
  • unset($_SESSION['oauth_weibo_state']) after validation prevents state token replay attacks, which could otherwise allow a second forged request to succeed
  • Social login callbacks are high-value targets — they sit at the boundary between an external identity provider and your application's session system, making them attractive for CSRF and session fixation attacks

How Orbis AppSec Detected This

  • Source: The code GET parameter ($_GET['code']) arriving at the OAuth callback URL — attacker-controlled input from any origin
  • Sink: Direct consumption of $_GET['code'] in the Weibo token exchange request at trunk/web/login_weibo.php:22, without any session-bound origin check
  • Missing control: No state parameter generated during authorization initiation, no session storage of expected state, and no comparison of $_GET['state'] against a stored value in the callback handler
  • CWE: CWE-352 — Cross-Site Request Forgery
  • Fix: Generated a 128-bit random state token with bin2hex(random_bytes(16)), stored it in $_SESSION['oauth_weibo_state'], appended it to the Weibo authorization URL, and added strict three-condition validation at the callback entry point with immediate token consumption via unset()

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The missing state parameter in login_weibo.php is a subtle but critical flaw — the OAuth flow worked correctly for legitimate users, making it easy to overlook in code review. Yet it left every user of this Online Judge platform vulnerable to a trivial 2-step CSRF attack requiring nothing more than a crafted image tag.

The fix is clean, minimal, and follows RFC 6749 exactly: generate a random token, bind it to the session, validate it on return. Two added blocks of code eliminate the entire attack surface without touching any other functionality.

OAuth 2.0 is widely implemented but frequently misimplemented. The state parameter is the single most commonly omitted security control in social login integrations. If you maintain any OAuth callback handler in PHP — or any language — verify today that you're generating, storing, and validating state on every authorization flow.


References

Frequently Asked Questions

What is an OAuth 2.0 CSRF attack?

An OAuth 2.0 CSRF attack exploits the authorization callback endpoint by tricking a victim's browser into completing an OAuth flow using an attacker-controlled authorization code, bypassing the origin check that the `state` parameter is designed to enforce.

How do you prevent OAuth CSRF in PHP?

Generate a cryptographically random state token with `bin2hex(random_bytes(16))`, store it in `$_SESSION`, append it to the authorization URL, and reject any callback where `$_GET['state']` does not match the stored session value.

What CWE is OAuth CSRF?

OAuth 2.0 CSRF is classified under CWE-352 (Cross-Site Request Forgery), because it exploits the trust a server places in requests that appear to come from an authenticated user's browser.

Is validating the redirect URI enough to prevent OAuth CSRF?

No. Validating the redirect URI only ensures the code is sent to the right endpoint, but it does not verify that the OAuth flow was initiated by the current user. The `state` parameter is required to bind the flow to the user's session.

Can static analysis detect missing OAuth state validation?

Yes. Tools like Semgrep can detect patterns where `$_GET['code']` is consumed in an OAuth callback without a corresponding `$_GET['state']` and session comparison. Orbis AppSec's multi-agent AI scanner flagged this exact pattern in login_weibo.php.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1184

Related Articles

critical

How Missing Authentication Middleware Happens in Node.js APIs and How to Fix It

A critical vulnerability in a Node.js Panel Connector API (CVE-2025-7783) left 14 endpoints—including shell command execution, file deletion, and file writing—completely open to unauthenticated access. The comment in the source code even declared "NO AUTH — Full Open Access," making it a textbook example of a missing authentication control. The fix adds a Bearer token middleware guard on all `/api` routes, blocking unauthorized requests before they reach any sensitive handler.

critical

How OAuth CSRF Attacks Happen in Node.js and How to Fix Them

A missing OAuth state parameter validation in `src/account_manager.js` left the `startOAuthServer()` function vulnerable to CSRF attacks, allowing an attacker to inject their own authorization code into a victim's active OAuth session. The fix generates a cryptographically random state token using `crypto.randomBytes()`, returns it alongside the server handle, and rejects any callback where the returned state doesn't match — closing the attack window entirely. This affects all downstream consume

critical

How Unauthenticated API Endpoint Exposure happens in Node.js and how to fix it

A critical vulnerability in `api/firebase-config.js` exposed all Firebase configuration values — including API keys, app IDs, and project IDs — to any unauthenticated caller. With no access controls, CORS restrictions, or rate limiting in place, attackers could retrieve live credentials and directly access Firebase services. The fix adds shared-secret authentication using timing-safe comparison, origin validation, and method enforcement.

high

How Middleware and Proxy Bypass happens in Next.js App Router and how to fix it

CVE-2026-64642 is a high-severity authentication bypass vulnerability in Next.js that affects App Router applications using Turbopack with a single locale configuration. The flaw allows attackers to circumvent middleware and proxy security controls, potentially gaining unauthorized access to protected routes. Upgrading from Next.js 16.2.7 to 16.2.11 closes the vulnerability entirely.

critical

How Unauthenticated API Endpoints happen in Node.js Express and how to fix it

The `/token` endpoint in `plugin/multiplex/index.js` generated presentation control tokens without verifying the requester's identity, allowing any attacker with network access to seize control of a live reveal.js presentation. The fix restricts token generation to localhost-only requests and replaces a broken cryptographic primitive with a proper SHA-256 hash. Together, these changes eliminate both the access-control gap and a secondary cryptographic weakness in a single targeted patch.

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript