Guide · Database script injection

A script injected into your posts runs in every visitor’s browser — and no file scan sees it.

Visitors are redirected, a pop-up appears that you never added, or view-source shows String.fromCharCode, document.write or a <script> tag pointing at a domain you do not recognise. If the theme and plugin files are clean, the script is very likely stored in the database: appended to post content, hidden in a widget, or written into the site address options themselves. This guide covers where those injections live, how to find every copy without changing anything, why serialised data has to be fixed through WP-CLI rather than raw SQL, and why removing the script is the smaller half of the job.

Symptom
Redirects, pop-ups or unknown scripts with clean theme and plugin files
Where it lives
Post content, widget options, siteurl and home, postmeta
Why it hides
Encoded with fromCharCode or atob so no URL appears in plain text
Fix with
WP-CLI search-replace, after a dry run — never a raw SQL REPLACE

What it is

Why the script is written as a list of numbers

String.fromCharCode turns numbers into characters: 104, 116, 116, 112 spell h, t, t, p. An injected script written this way never contains its destination as readable text, so a search for a domain name or for http finds nothing, and a quick look at a post in the editor shows what looks like noise. atob() does the same job with base64, and document.write or a created <script> element then loads the real payload from elsewhere.

The injection is usually stored where WordPress will print it into every page or every post: appended to the end of post_content, inside a Custom HTML or Text widget, in a page builder’s saved layout, or — in the bluntest version — by changing the siteurl and home options so the whole site loads from an attacker’s domain. Nothing in the files changes, so file-integrity checks, core reinstalls and plugin replacements all report a clean site.

Getting script into the database requires a write path: an exploited plugin, a stolen administrator login, cross-site scripting stored through a form or comment, or code already running on the server. The injection is the visible symptom of that access.

Find it

Read-only searches across posts, widgets and options

Each of these only reads. The table names assume the wp_ prefix; check $table_prefix in wp-config.php and substitute yours. Expect a few legitimate matches — embedded maps, analytics snippets, forms — and judge each by where its script loads from.

Figure 1 · Where injected scripts live

Four places a database script injection hides, and the safe way to fix each.

LocationWhat the injection looks likeSafe fix
wp_posts · post_content<script> or fromCharCode() appended to postssearch-replace --dry-run, then apply
widget_text, widget_custom_htmlA script inside a serialised widgetWP-CLI or the editor — never raw SQL
siteurl and home optionsChanged to an unfamiliar domainRestore the value, then find the writer
wp_postmeta, theme_mods_*Scripts in builder or theme dataSearch, then fix through WordPress
Widget and theme options are PHP-serialised, so they must be changed through WordPress or WP-CLI, never with a raw SQL REPLACE.
  1. 01

    Search post content

    Posts, pages and other post types whose content carries the usual markers:

    SQL · read-only
    SELECT ID, post_type, post_status, post_modified
    FROM wp_posts
    WHERE post_content LIKE '%fromCharCode%'
       OR post_content LIKE '%document.write(%'
       OR post_content LIKE '%atob(%'
       OR post_content LIKE '%<script%src=%'
    ORDER BY post_modified DESC;

    Injections are often appended to many posts in one pass. A cluster of matches with modification times minutes apart, on posts nobody edited, is the pattern.

  2. 02

    Search widgets and site-wide options

    Widgets are stored as options, so a script added to a sidebar lives here:

    SQL · read-only
    SELECT option_name, LENGTH(option_value) AS bytes
    FROM wp_options
    WHERE (option_name LIKE 'widget\_%' OR option_name LIKE 'theme\_mods\_%')
      AND (option_value LIKE '%<script%'
        OR option_value LIKE '%fromCharCode%'
        OR option_value LIKE '%atob(%');

    The backslash keeps the underscore literal: in a LIKE pattern an unescaped _ matches any single character.

  3. 03

    Check the site address options

    If the whole site redirects or loads from somewhere else, look here first:

    SQL · read-only
    SELECT option_name, option_value
    FROM wp_options
    WHERE option_name IN ('siteurl', 'home');

    Both should be your own address. A different domain here means something wrote directly to the database — treat it as a full compromise, not a misconfiguration.

  4. 04

    Search every table at once with WP-CLI

    WP-CLI’s database search covers postmeta, builder data and plugin tables in one pass, and only reads:

    WP-CLI
    wp db search fromCharCode --all-tables-with-prefix
    wp db search '<script' --all-tables-with-prefix

    Repeat with document.write and atob(. Note each table and column with a match; that list is your cleanup scope.

Why not raw SQL

Serialised data breaks if you edit it by hand

Widgets, theme options and most plugin settings are stored as PHP-serialised strings, and every string inside them records its own length.

What serialised data looks like

A widget value reads like a:2:{i:2;a:3:{s:5:"title";s:7:"Sidebar";…}} — each s:7 says the string that follows is exactly seven bytes long.

What a raw REPLACE does

Remove a 180-byte script with an SQL REPLACE and the recorded lengths no longer match. PHP then refuses to unserialise the value, and WordPress silently treats the whole option as empty: the widget, or the theme’s settings, disappear.

What WP-CLI does instead

WP-CLI’s search-replace unserialises each value, makes the change, and serialises it again with corrected lengths. It works on post content too, so one tool covers every location.

Preview before you change anything

A dry run reports every table and column it would change without writing:

WP-CLI
wp search-replace 'INJECTED_STRING' '' --dry-run --report-changed-only

INJECTED_STRING must be the exact injected text, copied from the database. If the injection varies from post to post, fix those posts individually instead of guessing at a pattern.

Remove it

Clean the content, then close the write path

Removing the script is quick. The order around it decides whether it stays removed.

  1. 01

    Preserve the evidence

    Export the database outside the web root before changing anything:

    shell
    mkdir -p ~/incident
    wp db export ~/incident/before-cleanup.sql

    Record which posts, widgets and options carried the injection and their modification times. They bound the window to search the logs for.

  2. 02

    Restore the site address first, if it changed

    A hijacked siteurl or home locks you out of the dashboard. Defining WP_HOME and WP_SITEURL in wp-config.php overrides the stored values until the options are corrected — remove the defines afterwards if you do not normally use them.

  3. 03

    Remove the injection from content

    Run the dry run until the counts match what you found, then repeat the same command without --dry-run. Fix widgets the same way or by editing them in Appearance → Widgets; never with a raw SQL REPLACE.

  4. 04

    Find what wrote it

    An injection in many posts at once points to code or database access, not someone typing. Look for the file-based loader and the account: rogue administrators, recently changed plugin files, PHP in uploads, and anything in mu-plugins.

  5. 05

    Rotate and re-check

    Administrator passwords, application passwords, the database password and the WordPress salts. Then re-run the searches the next day: content that is re-injected means the writer is still there.

Related symptoms

What usually travels with a database script injection

A script injection is often one layer among several. The same access that wrote to post content can store an encoded PHP payload in the options table, alter role definitions, or schedule a WP-Cron event that re-injects the content after you clean it. Google may also have noticed before you did.

Common questions

Common questions about database script injections

How do I see what a fromCharCode string decodes to?

Convert the numbers to characters as text, without executing anything — the decoder tool on this site does it entirely in your browser. Do not paste the snippet into a browser console, which would run it, and do not visit the address it reveals.

Why does the post look normal in the editor?

The block editor may show the script as a Custom HTML block, keep it in code view, or not display it at all if it sits after the closing markup. Search the database rather than relying on what the editor shows.

Can I fix it with an SQL REPLACE in phpMyAdmin?

For plain post_content a careful REPLACE works, but widgets, theme options and most plugin data are serialised, and a REPLACE that changes a string’s length breaks them. WP-CLI search-replace handles both safely and has a dry-run mode, so use it everywhere.

I removed the script and it came back the next day. Why?

The thing that wrote it is still active: a loader file, a scheduled event, a rogue administrator, or a still-vulnerable plugin. Find and remove the writer, then clean the content again.

Does a security plugin find database injections?

Some scan selected tables for known signatures, and many miss encoded scripts. A file scan never sees them. A direct database search, as above, is the reliable check.