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\n WHERE post_content LIKE '%<script%'\n    OR post_content LIKE '%eval(%'\n    OR post_content LIKE '%base64_decode%'\n LIMIT 50;\n\nSELECT option_name, LEFT(option_value, 120) FROM wp_options\n 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\ngrep -n -E '<script|eval\(|base64_decode|document\.write' /tmp/scan.sql | head -40\nshred -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.