Security incidents happen. What matters is how quickly you can respond. Here’s how I cleaned a compromised WordPress database when I discovered malicious JavaScript injected into hundreds of posts.
The Discovery
During a routine site check, I noticed suspicious external script loading on multiple pages. The pattern was consistent:
<script src='https://malicious-cdn[.]example/m.js?n=ns1' type='text/javascript'></script>
This script was appearing in post content across the entire site. Classic injection attack—probably from a compromised admin account or vulnerable plugin.
Note: Domain defanged ([.] instead of .) for security reasons. This was a real attack using a suspicious .ga domain.
The Damage Assessment
First, I needed to know the scope:
Security incidents happen. What matters is how quickly you can respond. Here’s how I cleaned a compromised WordPress database when I discovered malicious JavaScript injected into hundreds of posts.
The Discovery
During a routine site check, I noticed suspicious external script loading on multiple pages. The pattern was consistent:
<script src='https://malicious-cdn[.]example/m.js?n=ns1' type='text/javascript'></script>
This script was appearing in post content across the entire site. Classic injection attack—probably from a compromised admin account or vulnerable plugin.
Note: Domain defanged ([.] instead of .) for security reasons. This was a real attack using a suspicious .ga domain.
The Damage Assessment
First, I needed to know the scope:
SELECT COUNT(*)
FROM wp_posts
WHERE post_content LIKE '%malicious-cdn[.]example%';
Result: 347 affected posts. Too many to manually clean.
The attacker had injected the script tag into the post_content field of the wp_posts table. Smart attackers target this because:
- Content is rarely validated after initial save
- It affects published and draft content
- It persists through theme changes
- Most security plugins don’t scan post content by default
The One-Query Solution
Instead of writing a complex PHP script or plugin, I used SQL’s REPLACE function:
UPDATE wp_posts
SET post_content = REPLACE(
post_content,
"<script src='https://malicious-cdn[.]example/m.js?n=ns1' type='text/javascript'></script>",
""
)
WHERE post_content LIKE '%malicious-cdn[.]example%';
Why this works:
REPLACEsearches the entire content field- Removes only the exact malicious string
- Preserves all other content
- Executes in seconds even on thousands of posts
Verification Steps
After running the cleanup, I verified:
1. Check Affected Rows
SELECT COUNT(*)
FROM wp_posts
WHERE post_content LIKE '%malicious-cdn[.]example%';
Should return 0.
2. Spot Check Random Posts
SELECT ID, post_title, post_content
FROM wp_posts
WHERE post_type = 'post'
AND post_status = 'publish'
ORDER BY RAND()
LIMIT 10;
3. Check the Site
Visit the site in incognito mode and inspect the source. Search for the suspicious domain name.
The Broader Security Fix
Cleaning the database is step one. Here’s what I did next:
Immediate Actions
- Changed all admin passwords (especially super admin)
- Checked for suspicious admin users:
SELECT * FROM wp_users WHERE user_email LIKE '%suspicious-domain%';
SELECT * FROM wp_users ORDER BY user_registered DESC LIMIT 10;
- Reviewed recent plugin installations:
SELECT option_value FROM wp_options WHERE option_name = 'recently_activated';
Hardening Steps
- Updated everything: WordPress core, themes, plugins
- Removed unused plugins and themes
- Implemented security headers in
.htaccess - Added file integrity monitoring
- Enabled 2FA for all admin accounts
Prevention Strategy
To prevent future attacks, I added a content filter that blocks unauthorized external scripts.
// Added to functions.php - strips unauthorized script tags from post content on save
add_filter('content_save_pre', function($content) {
// Allow only whitelisted domains
$allowed_domains = ['youtube.com', 'vimeo.com', 'trusted-cdn.com'];
// Match script tags with src attributes (with flexible spacing)
preg_match_all('/<script[^>]*src\s*=\s*["\']([^"\']*)["\'][^>]*>/i', $content, $matches);
if (!empty($matches[0])) {
foreach ($matches[0] as $key => $script_tag) {
$src = $matches[1][$key];
// Skip relative URLs (local scripts are safe)
if (strpos($src, '//') === false) {
continue;
}
// Parse URL and handle errors
$parsed = parse_url($src);
// If parse fails or no host, block it (likely malformed)
if ($parsed === false || empty($parsed['host'])) {
$content = str_replace($script_tag, '', $content);
error_log("Blocked malformed script URL: $src");
continue;
}
$domain = $parsed['host'];
// Remove www. for comparison
$domain = preg_replace('/^www\./i', '', $domain);
// Check if domain or parent domain is in whitelist
$is_allowed = false;
foreach ($allowed_domains as $allowed) {
// Case-insensitive check for exact match or subdomain
if (strcasecmp($domain, $allowed) === 0 ||
preg_match('/\.' . preg_quote($allowed, '/') . '$/i', $domain)) {
$is_allowed = true;
break;
}
}
if (!$is_allowed) {
// Remove this specific script tag only
$content = str_replace($script_tag, '', $content);
error_log("Blocked unauthorized script from: $domain (original: $src)");
}
}
}
return $content;
});
What this handles correctly:
- ✅ Subdomains:
m.youtube.com,www.youtube.com - ✅ Local scripts:
/js/app.js(allowed) - ✅ Case insensitive:
YOUTUBE.COM=youtube.com - ✅ Malformed URLs: Caught and logged
- ✅ Subdomain wildcards:
cdn.vimeo.comallowed ifvimeo.comwhitelisted
Test Cases:
// ✅ These get ALLOWED
<script src="/js/local.js"></script>
<script src="https://www.youtube.com/embed.js"></script>
<script src="https://cdn.vimeo.com/player.js"></script>
// ❌ These get BLOCKED
<script src="https://evil-site.com/malware.js"></script>
<script src="https://youtubee.com/fake.js"></script> // Typosquatting
<script src="//suspicious.ga/script.js"></script>
Important Limitations
Before using this in production, consider:
⚠️ Performance: Runs on every post save (auto-saves included)
⚠️ Silent operation: Users won’t be notified when scripts are removed
⚠️ Limited scope: Only catches <script src=""> tags (not inline scripts or obfuscated code)
⚠️ Hardcoded whitelist: No admin UI for managing allowed domains
⚠️ No backup: Content is modified immediately without versioning
Better for production:
- Add caching to avoid processing unchanged content
- Implement admin notifications for blocked scripts
- Create a settings page for whitelist management
- Consider using Content Security Policy headers instead
- Use WordPress revision system to track changes
This code is solid for emergency response and small sites, but high-traffic sites should consider a more robust solution with proper admin controls and performance optimization.
Lessons Learned
1. Database Direct Access > Plugin Overhead
For bulk operations, direct SQL is often faster and more reliable than WordPress plugins. Know when to bypass the abstraction layer.
2. Search Before You Replace
Always run a SELECT query first to see what you’re affecting. Never run an UPDATE blind.
3. Test Your Security Code Thoroughly
The first version of my content filter had bugs! Edge cases matter in security:
- Subdomain handling
- URL parsing errors
- Local vs external scripts
- Case sensitivity
Always test with both valid and malicious inputs.
4. Understand Your Code’s Limitations
No security solution is perfect. Know what your code does and doesn’t protect against. Document limitations for future maintainers.
5. Backup Before Everything
I had a recent backup, so if this went wrong, I could restore. If you don’t have automated backups, stop reading and set them up now.
6. Security is Ongoing
This wasn’t a one-time fix. Security requires:
- Regular updates
- Activity monitoring
- Access audits
- User education
- Layered defense
The Quick Reference
Save this for emergencies:
-- 1. Find the malicious code
SELECT ID, post_title
FROM wp_posts
WHERE post_content LIKE '%suspicious-pattern%';
-- 2. Backup first!
CREATE TABLE wp_posts_backup AS SELECT * FROM wp_posts;
-- 3. Clean it
UPDATE wp_posts
SET post_content = REPLACE(post_content, '<malicious-code>', '')
WHERE post_content LIKE '%suspicious-pattern%';
-- 4. Verify
SELECT COUNT(*) FROM wp_posts WHERE post_content LIKE '%suspicious-pattern%';
-- 5. Check users
SELECT * FROM wp_users ORDER BY user_registered DESC;
-- 6. Check options
SELECT * FROM wp_options WHERE option_value LIKE '%suspicious%';
Tools That Helped
- Wordfence: Post-cleanup scan
- UpdraftPlus: Automated backups
- Adminer: Lightweight database management for safe query testing
What Would You Do?
Have you dealt with WordPress hacks? What’s your go-to cleanup strategy?
I’m particularly interested in:
- Automated malware detection approaches
- Prevention strategies that actually work
- Balancing security with site performance
- Bugs you’ve found in security code (we all write them!)
- When you’d use a filter vs CSP headers
Drop your thoughts below! 👇