How Session Fixation Happens in PHP OAuth Login Handlers and How to Fix It
Summary
A session fixation vulnerability in trunk/web/login_weibo.php allowed attackers to hijack authenticated user sessions by pre-setting a victim's PHP session ID before they logged in via Weibo OAuth. The fix was a single, critical call to session_regenerate_id(true) inserted immediately before assigning the authenticated user's ID to the session. Left unpatched, this vulnerability could have given an attacker full access to any account whose Weibo OAuth login they could observe or influence.
Introduction
The login_weibo.php file handles the OAuth callback flow for Weibo-based authentication — one of the most sensitive code paths in any web application. After exchanging the OAuth authorization code for an access token and resolving the user's identity, the handler writes the authenticated username into the PHP session:
$_SESSION[$OJ_NAME . '_' . 'user_id'] = $uname;
header("Location: ./");
This looks innocuous. But there is a critical missing step: the session ID is never rotated at the moment of privilege escalation. The same session token that existed before the user authenticated continues to exist after they authenticate — and that is the heart of a session fixation vulnerability.
The Vulnerability Explained
What is session fixation?
In a normal session hijacking attack, an adversary must steal a session token the server has already issued to a victim. Session fixation flips this: the attacker plants a known session token on the victim before they log in, then waits. Once the victim completes authentication, the server binds their authenticated identity to the attacker's pre-known token — and the attacker can immediately use that token to access the account.
The vulnerable code (before the fix)
Here is the exact code path in login_weibo.php around line 47–68:
// After OAuth token exchange and user lookup/creation:
pdo_query($sql, $uname, $email, $ip, $password, $nick, $school);
// login it
$_SESSION[$OJ_NAME . '_' . 'user_id'] = $uname; // ← VULNERABLE: session ID unchanged
// redirect it
header("Location: ./");
The session variable $OJ_NAME . '_' . 'user_id' is set to $uname (the resolved Weibo username) without any call to session_regenerate_id(). PHP happily reuses the session file and cookie that was created when the unauthenticated user first visited the site.
Step-by-step attack scenario
This is a 3-step attack chain specific to this OAuth handler:
Step 1 — Session planting. The attacker visits the application to obtain a valid (unauthenticated) session ID, for example PHPSESSID=attacker_known_value. They then deliver this session cookie to the victim. Two practical delivery methods apply here:
- Subdomain cookie injection: If the application runs at
oj.example.comand any subdomain (e.g.,static.example.com) can be influenced by the attacker, aSet-Cookieresponse from that subdomain withDomain=.example.comwill plant the cookie in the victim's browser. - Network-level MITM: On a shared network (coffee shop Wi-Fi, corporate proxy), the attacker intercepts an HTTP response and injects a
Set-Cookieheader.
Step 2 — Victim authenticates. The victim clicks "Login with Weibo," completes the OAuth flow, and login_weibo.php runs. Because the victim's browser already holds PHPSESSID=attacker_known_value, PHP loads that session. The handler then executes:
$_SESSION[$OJ_NAME . '_' . 'user_id'] = $uname;
The victim's Weibo username is now stored in the session file identified by attacker_known_value.
Step 3 — Session reuse. The attacker, who has always known attacker_known_value, sends a request with Cookie: PHPSESSID=attacker_known_value. The server reads the session, finds user_id = victim_username, and treats the attacker as the fully authenticated victim.
Real-world impact
- Full account takeover of any user who completes Weibo OAuth login while the attacker's planted cookie is active.
- The attacker does not need to know the victim's Weibo credentials.
- The attack is silent — no failed login attempts, no brute-force signals.
- In a containerized deployment (as noted in the threat model), if the application is network-exposed, this attack surface is reachable from outside the container network.
The Fix
The repair is minimal but decisive. A single line was inserted at line 68 of login_weibo.php, immediately before the session write:
Before
// login it
$_SESSION[$OJ_NAME . '_' . 'user_id'] = $uname;
// redirect it
header("Location: ./");
After
// login it
session_regenerate_id(true); // ← NEW: invalidate pre-auth session, issue fresh ID
$_SESSION[$OJ_NAME . '_' . 'user_id'] = $uname;
// redirect it
header("Location: ./");
Why this specific change works
session_regenerate_id(true) does two things atomically:
- Issues a new session ID and sends a fresh
Set-Cookie: PHPSESSID=<new_random_value>header to the client. - Deletes the old session file on the server (the
trueargument). This is important: withouttrue, the old session file remains on disk, and PHP may still accept the old ID depending on server configuration.
After this call, the attacker's pre-planted PHPSESSID=attacker_known_value is worthless. The server has deleted the corresponding session file, and the victim's browser now holds a brand-new session ID that the attacker does not know. The authenticated user_id is written only into this new session.
The placement matters: session_regenerate_id(true) must be called after the authentication decision is made (so we know the login succeeded) but before any privileged data is written to $_SESSION. This fix gets that ordering exactly right.
Prevention & Best Practices
Always regenerate session IDs at privilege escalation
The rule is simple: any time a session transitions from a lower-privilege state to a higher-privilege state, regenerate the session ID. This includes:
- Successful username/password login
- Successful OAuth callback (exactly this case)
- Successful MFA verification
- "Remember me" token redemption
- Password reset completion
// Correct pattern for any PHP login handler
if (authenticate_user($username, $password)) {
session_regenerate_id(true); // Must come first
$_SESSION['user_id'] = $username; // Then write auth state
header("Location: /dashboard");
}
Use true with session_regenerate_id()
Always pass true to delete the old session. Omitting this argument leaves an orphaned session file that could be reused in edge cases:
session_regenerate_id(); // ❌ Old session file persists
session_regenerate_id(true); // ✅ Old session file deleted
Harden session cookies
Complement session regeneration with secure cookie attributes in php.ini or at runtime:
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => 'oj.example.com', // Exact domain, not .example.com
'secure' => true, // HTTPS only
'httponly' => true, // No JavaScript access
'samesite' => 'Lax', // CSRF mitigation
]);
Setting domain to the exact hostname (not a wildcard subdomain) also closes the subdomain cookie injection vector described in the attack scenario above.
Detect this pattern with static analysis
Semgrep can flag PHP files that write to $_SESSION after an authentication event without a preceding session_regenerate_id() call. A simple rule targets the exact pattern found here:
rules:
- id: php-session-fixation
patterns:
- pattern: $_SESSION[$KEY] = $VAL;
- pattern-not-inside: |
session_regenerate_id(...);
...
$_SESSION[$KEY] = $VAL;
message: "$KEY written to session without prior session_regenerate_id()"
languages: [php]
severity: WARNING
Security standards
- OWASP Session Management Cheat Sheet explicitly mandates session ID regeneration after login.
- CWE-384: Session Fixation describes this exact weakness class.
- ASVS v4.0, Requirement 3.3.1: "Verify that logout and expiration invalidate the session token, such that the back button or a downstream relying party does not resume an authenticated session."
Key Takeaways
session_regenerate_id(true)is mandatory in every PHP OAuth callback handler — not optional, not a "nice to have."login_weibo.phpis a textbook example of what happens when it is omitted.- OAuth login handlers are high-value targets because they involve cross-domain redirects, making cookie planting via subdomain injection especially practical.
- The
trueargument tosession_regenerate_id()matters — without it, the old session file survives on disk and the protection is incomplete. - Placement is everything: regenerate the session ID after confirming authentication success but before writing
$_SESSION[$OJ_NAME . '_' . 'user_id']. - Restricting the session cookie domain to the exact hostname (not a wildcard) removes the subdomain injection delivery vector that makes this attack practical in shared-domain deployments.
How Orbis AppSec Detected This
- Source: The Weibo OAuth callback delivers an authenticated user identity (
$uname) via the OAuth token exchange flow inlogin_weibo.php. - Sink:
$_SESSION[$OJ_NAME . '_' . 'user_id'] = $unameat line 68 oftrunk/web/login_weibo.php— a privileged session write with no preceding session ID rotation. - Missing control: No call to
session_regenerate_id()between the authentication decision and the session variable assignment. - CWE: CWE-384 — Session Fixation.
- Fix: Inserted
session_regenerate_id(true)on the line immediately preceding the$_SESSIONassignment, ensuring the pre-authentication session ID is invalidated and deleted before any authenticated state is stored.
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
Session fixation is one of those vulnerabilities that is trivially easy to introduce and trivially easy to fix — but only if you know to look for it. In login_weibo.php, the entire attack surface was a single missing function call. An attacker exploiting this would have needed no knowledge of the victim's credentials, would have left no failed-login traces in logs, and could have achieved full account takeover silently.
The lesson for any developer writing PHP authentication code — whether for OAuth, SAML, password login, or MFA — is to treat session_regenerate_id(true) as a non-negotiable step in the login sequence, as automatic as calling session_start(). Pair it with tight cookie domain scoping, Secure and HttpOnly flags, and automated static analysis, and this entire class of vulnerability becomes effectively unreachable.