DEV Community

Mohamed Amine Yaakoubi
Mohamed Amine Yaakoubi

Posted on

How I Cleaned a Hacked WordPress Database in 5 Minutes

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>
Enter fullscreen mode Exit fullscreen mode

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%';
Enter fullscreen mode Exit fullscreen mode

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%';
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • REPLACE searches 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%';
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

  1. Changed all admin passwords (especially super admin)
  2. 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;
Enter fullscreen mode Exit fullscreen mode
  1. Reviewed recent plugin installations:
   SELECT option_value FROM wp_options WHERE option_name = 'recently_activated';
Enter fullscreen mode Exit fullscreen mode

Hardening Steps

  1. Updated everything: WordPress core, themes, plugins
  2. Removed unused plugins and themes
  3. Implemented security headers in .htaccess
  4. Added file integrity monitoring
  5. 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;
});
Enter fullscreen mode Exit fullscreen mode

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.com allowed if vimeo.com whitelisted

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>
Enter fullscreen mode Exit fullscreen mode

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%';
Enter fullscreen mode Exit fullscreen mode

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! 👇

Top comments (0)