A clean file scan is not a clean site. Injected scripts live comfortably in content: a post body, a widget, a settings row, a theme option. Reinstalling the application replaces the files and leaves every one of those exactly where it was.

Look for it

SELECT * FROM wp_posts
 WHERE post_content LIKE '%<script%'
    OR post_content LIKE '%eval(%'
    OR post_content LIKE '%base64_decode%'
 LIMIT 50;

SELECT option_name, LEFT(option_value, 120) FROM wp_options
 WHERE option_value LIKE '%<script%' LIMIT 50;

The same idea applies to any schema: the text columns visitors see, plus the settings table. A search for a script tag in content that should never contain one finds most of it.

Search the whole dump instead

Faster than guessing at tables, and it works on a schema you do not know.

mysqldump -u user -p dbname > /tmp/scan.sql
grep -n -E '<script|eval\(|base64_decode|document\.write' /tmp/scan.sql | head -40
shred -u /tmp/scan.sql

Clean it

  1. Back up the database first — you are about to run UPDATE statements against live content.
  2. Fix one row by hand — confirm the exact string, and that removing it leaves valid content.
  3. Then the rest, scoped narrowlyUPDATE wp_posts SET post_content = REPLACE(post_content, '<script src="//bad.example"></script>', '') WHERE post_content LIKE '%bad.example%';
  4. Check the count before and after — a REPLACE that touches more rows than your SELECT found means the pattern is too broad.
Never run a broad REPLACE across every text column at once. It is the fastest way to turn a compromise into data loss, and the backup you took two minutes ago becomes the only copy of your content.

Then close the door

Content is injected through something: an old plugin, a stolen admin password, a writable uploads folder. Cleaning the rows without finding that means it comes back within days. See cleaning a hacked site properly.