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:
-
Attacker initiates their own OAuth flow against the target OJ instance, obtaining a valid Weibo authorization code (
ATTACKER_CODE) linked to their Weibo account. -
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"> -
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. -
The server exchanges
ATTACKER_CODEfor an access token tied to the attacker's Weibo account and logs the victim in as the attacker. -
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.phpaccepted$_GET['code']with zero origin verification — any page on the internet could trigger a Weibo login as any Weibo user- The
stateparameter 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 userand(),mt_rand(), oruniqid()for security tokensunset($_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
codeGET 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 attrunk/web/login_weibo.php:22, without any session-bound origin check - Missing control: No
stateparameter 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 viaunset()
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.