Del Security

Security Research

Virtualizor Pre-auth Admin Takeover via OAuth Email SQL Injection

Virtualizor's social-login callback concatenates the email address returned by the OAuth provider straight into a SQL query. Because the email itself is the injection vector, the attack needs no special infrastructure -- just an ordinary OAuth account whose address carries the payload. We show why `x'OR/**/uid=1#@mydomain.com` is a valid email you can actually register on GitHub (and other providers), how it turns into an unauthenticated admin session, and why the vendor's 'could not reproduce' verdict missed the mark.

10 min read
#virtualizor#sql injection#oauth#authentication bypass#vulnerability research

An unauthenticated attacker with one ordinary OAuth account logs in as the Virtualizor administrator — no password, no 2FA, no server-side foothold.

We hold a working PoC that automates the full OAuth flow and returns an authenticated admin session URL. Because the bug is patched and the attack only requires an OAuth account whose email carries the payload, we describe the technique and the exact payload here but withhold the turn-key script. For vendor verification or red-team engagements, contact us.


TL;DR

  • DVE-2026-020 — pre-authentication SQL injection in Virtualizor’s OAuth social-login callback (enduser/auth_callback.php).
  • The email address returned by the OAuth provider is concatenated directly into a SELECT ... WHERE email = '<input>' query. An OR uid=1 payload forces the administrator row to be returned, and its uid is written straight into the session. Instant admin takeover — no password, no 2FA.
  • The email is the injection string. RFC 5321/5322 permit the required characters in the local-part, so the payload travels as an ordinary address. We verified GitHub accepts and verifies it (screenshot below) — and because Virtualizor never checks the provider’s email_verified flag, any provider that returns such an address qualifies, including ones where the attacker controls the email claim outright.
  • Prerequisite: Social Login enabled with at least one OAuth provider configured.
  • Discovered against Virtualizor 3.2.8.5. Patched in 3.2.9 (2026-05-12). No CVE assigned; the vendor rated it a low-severity “hypothetical” issue.

How social login works in Virtualizor

Virtualizor’s end-user panel offers optional social login built on the HybridAuth library. The flow is unremarkable:

  1. The user clicks “Login with GitHub” (or Google, Discord, a custom OpenID Connect provider, etc.).
  2. HybridAuth runs the OAuth handshake and calls getUserProfile(), which returns a profile object including email.
  3. enduser/auth_callback.php looks the user up by email and, if found, drops the matching uid into the session.

That third step is where identity is decided. Virtualizor trusts the email string the provider hands back and uses it to find — and become — a local account. Everything depends on that lookup being safe.

It is not.


The Vulnerability

1. The email goes straight into SQL

The callback takes the provider-supplied email and concatenates it into a query string (reconstructed from the IonCube-encoded auth_callback.php):

// enduser/auth_callback.php
$data['email'] = $userProfile->email;        // from HybridAuth -- attacker-controlled, unsanitized

$qresult = makequery(
    "SELECT uid, email FROM users WHERE email = '" . $data['email'] . "'"
);
$row = vsql_fetch_assoc($qresult);
$SESS['uid'] = $row['uid'];                   // the SQLi result becomes the session identity

The injected value never passes through Virtualizor’s input helpers (optGET() / optPOST() / optREQ()); it is assigned directly from the OAuth API response. The same unsanitized $data['email'] is reused in a second query on the registration path.

Worse, the fix was already sitting in the codebase. makequery() supports bound parameters and is called safely elsewhere — for example in main/sessions.php:

// SAFE -- parameterized (used in sessions.php)
makequery("SELECT ... WHERE email = :email", [':email' => $data['email']]);

// UNSAFE -- what auth_callback.php does
makequery("SELECT ... WHERE email = '" . $data['email'] . "'");

The binding capability exists and is used in neighboring files. The HybridAuth integration simply didn’t use it.

2. Nothing validates the email

The only check between getUserProfile() and the query is an emptiness test:

if (empty($data['email'])) {
    exit('No email details were returned !');
}
// no format check (no filter_var / FILTER_VALIDATE_EMAIL)
// no length check
// no check of the provider's email_verified flag

Confirmed at the opcode level: between the getUserProfile call and makequery, there is no filter_var, no preg_match, no strlen, and no inspection of the provider’s verification flag. Virtualizor consumes $userProfile->email — not $userProfile->emailVerified — so whether the provider considered the address verified is irrelevant to the code path.

The application imposes zero constraints on the email. The only gatekeeper left standing is the OAuth provider itself.


The Payload Is an Email Address

Here is the part that turns a “you’d need a malicious OAuth server” footnote into a real-world pre-auth bug.

Per RFC 5321/5322, the local-part of an email address may contain, unquoted, every character in the atext set:

A-Z a-z 0-9  !  #  $  %  &  '  *  +  -  /  =  ?  ^  _  `  {  |  }  ~

Note what is in there: the single quote ', the slash and star (/, *), the equals sign =, and the hash #. That is exactly the alphabet a MySQL injection needs:

  • ' closes the string literal,
  • /**/ substitutes for a space (MySQL treats a comment as whitespace),
  • # opens a line comment that swallows the trailing quote and the @domain tail.

So this is a fully RFC-valid, unquoted email address:

x'OR/**/uid=1#@mydomain.com

Drop it through the vulnerable query and you get:

SELECT uid, email FROM users WHERE email = 'x'OR/**/uid=1#@mydomain.com'

which MySQL parses as:

SELECT uid, email FROM users WHERE email = 'x' OR uid = 1   -- #@mydomain.com' commented out

The OR uid=1 forces the administrator row (uid 1) into the result, $SESS['uid'] is set to 1, and the attacker holds an admin session. The local-part is 14 characters — comfortably inside the 64-octet RFC limit — and the keywords are case-insensitive, so provider-side lowercasing doesn’t break it.


”Can you actually register that email?”

This was the vendor’s central objection, and it is a fair question. The original report’s illustrative payload, foo' OR uid=1 -- -, contains spaces and the -- comment. An address like that requires the quoted-local-part form ("foo' OR uid=1 -- -"@...), which mainstream sign-up validators reject. Test the bug with that string against GitHub or Google and you will conclude, reasonably, that you cannot create the account.

The all-atext variant is the difference between “could not reproduce” and “works.” GitHub accepts x'OR/**/uid=1#@mydomain.com at sign-up and add-email, and sends a verification mail. The attacker runs the MTA for mydomain.com, receives that mail, and confirms the address — it is now a verified, primary email on a real GitHub account:

GitHub accepts an email address that carries a SQL-injection payload through its validation and verification flow

From there the chain is mechanical: HybridAuth’s GitHub provider returns that primary email verbatim into $userProfile->email, and Virtualizor concatenates it, unmodified, into the query. No attacker-controlled OAuth endpoint, no RFC edge case that providers strip — a normal “Login with GitHub” carries the payload end to end.

It is not just GitHub

GitHub is the strongest single proof — a major provider that verifies email ownership still accepts the address — but the bug does not depend on it. Virtualizor trusts an externally supplied string as both identity and SQL, so any provider that can deliver that string is a valid entry point. Three classes qualify:

  1. Permissive validators. Any provider whose sign-up / add-email form accepts atext specials will store and return the payload. GitHub is the demonstration; it is not unique. The same family of addresses passes other providers’ validators that follow the RFC rather than a stricter house regex.
  2. Providers that return unverified emails. Virtualizor reads $userProfile->email, never email_verified. A provider that hands back an email the user merely typed — without confirming ownership — is already enough. The verification step matters only to the provider, not to Virtualizor.
  3. Providers the attacker controls. Generic OAuth2 and self-hosted OpenID Connect endpoints let the attacker set the email claim to anything. Here there is no registration trick at all: the userinfo response simply returns x'OR/**/uid=1#@.... This is the path our lab PoC used to drive the chain deterministically, and it works against any Virtualizor instance configured to trust such a provider.

The common thread is that the injection is provider-agnostic. Which OAuth source delivers the payload — a permissive major provider, an unverified-email provider, or one the attacker stands up themselves — is an implementation detail, not a precondition.


Impact

A single ordinary OAuth account yields, with no prior authentication:

ImpactDescription
Admin takeoverOR uid=1 returns the administrator row; its uid becomes the session identity. Password and 2FA are never consulted.
Full DB / secret accessAn admin session (or, where the 64-char email budget allows, a UNION variant) exposes the users table password hashes and the master API key in the api table.
API + infrastructure controlThe master API key carries every act_* permission — create/delete/modify any VPS across the cluster.

In-email UNION exfiltration is constrained by the 64-octet local-part limit, so long extraction payloads don’t fit in a single address — but they don’t need to. The short admin-bypass payload alone hands over the full administrator panel, and everything else follows from there. (An attacker-controlled provider, per class 3 above, has no such length constraint and can return arbitrarily long UNION payloads directly.)


Affected Versions

  • Confirmed: Virtualizor 3.2.8.5 (latest at disclosure time).
  • The vulnerable component is the HybridAuth social-login integration in enduser/auth_callback.php; builds shipping the same integration are expected to be affected.
  • Requirement: Social Login enabled and at least one OAuth provider configured. This is not a default-on feature, which bounds the exposed population — but where it is enabled, the attack is pre-authentication, provider-agnostic, and reliable.

CWE-89 (SQL Injection). We assess CVSS 3.1 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) for an instance with social login enabled. No CVE was assigned.


Patch

Virtualizor 3.2.9 shipped on 2026-05-12 and addresses the injection. From the release notes:

A hypothetical SQL injection vulnerability in the authentication callback handler has been patched. User input is now properly sanitised to prevent malicious database queries.

The fix is the right one — bind the email instead of concatenating it:

// 3.2.8.5 (vulnerable)
makequery("SELECT uid, email FROM users WHERE email = '" . $data['email'] . "'");

// 3.2.9 (fixed) -- parameterized
makequery("SELECT uid, email FROM users WHERE email = :email", [':email' => $data['email']]);

If you run social login on Virtualizor 3.2.8.5 or earlier, update to 3.2.9. As a stop-gap, disabling social login removes the attack surface entirely.

A note on “hypothetical”

The vendor classified the issue as a low-severity, “could not reproduce” hypothetical. That verdict came from testing a space-bearing payload that sign-up validators reject — not from the all-atext variant that GitHub (and other providers) accept. The code defect is unambiguous (unbound concatenation of an externally controlled string), the patch confirms it, and the provider-side reproduction is straightforward once the payload is written in characters the local-part actually allows. We assess this as a genuine pre-auth admin takeover for any instance with social login enabled, not a theoretical one unlike vendor’s changelog.


Disclosure

The exchange below is worth recording because it is exactly where the “hypothetical” framing came from — and where it falls down.

DateEvent
2026-04-03SQL injection discovered during a whitebox audit of Virtualizor 3.2.8.5; runtime-confirmed in an isolated Docker environment
2026-04-20Reported to the vendor
2026-04-29We asked whether a patch had shipped; the vendor replied that the fix would land in the next release
2026-05-04Vendor asked: “Were you able to create an email on GitHub or any other provider with special characters as described in the PoC?“
2026-05-04We replied: “We are able to register an email like x'OR/**/uid=1#@mydomain.com on GitHub.”
2026-05-12Virtualizor 3.2.9 released with the parameterized fix
2026-06-22Del Security publishes this analysis (DVE-2026-020)

DVE-2026-020. Patched in Virtualizor 3.2.9 (2026-05-12).

For commercial use or inquiries, please contact us.