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.
Guide · Database script injection
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.
Published Last reviewed
What it is
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
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.
| Location | What the injection looks like | Safe fix |
|---|---|---|
| wp_posts · post_content | <script> or fromCharCode() appended to posts | search-replace --dry-run, then apply |
| widget_text, widget_custom_html | A script inside a serialised widget | WP-CLI or the editor — never raw SQL |
| siteurl and home options | Changed to an unfamiliar domain | Restore the value, then find the writer |
| wp_postmeta, theme_mods_* | Scripts in builder or theme data | Search, then fix through WordPress |
Posts, pages and other post types whose content carries the usual markers:
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.
Widgets are stored as options, so a script added to a sidebar lives here:
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.
If the whole site redirects or loads from somewhere else, look here first:
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.
WP-CLI’s database search covers postmeta, builder data and plugin tables in one pass, and only reads:
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
Widgets, theme options and most plugin settings are stored as PHP-serialised strings, and every string inside them records its own length.
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.
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.
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.
A dry run reports every table and column it would change without writing:
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
Removing the script is quick. The order around it decides whether it stays removed.
Export the database outside the web root before changing anything:
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.
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.
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.
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.
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
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
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.
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.
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.
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.
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.