During a WordPress SEO spam investigation, I found Google requesting URLs like these:
https://example.com/?p=23932
https://example.com/?q=98237492
https://example.com/?a=739284729
At first, they look similar: a short query parameter followed by a large number.
But they should not all be treated the same way.
The most dangerous mistake would be to create a rule that blocks every single-letter query parameter.
That could break:
- Real WordPress posts
- Internal searches
- Plugin filters
- Tracking parameters
- Form submissions
- Application features
In a larger cleanup involving approximately 242,000 unwanted URL variations, I had to separate legitimate WordPress behaviour from malicious URL patterns.
The full incident is documented in my spam URL removal case study. This article focuses on query-string analysis.
What is a query string?
A query string is the part of a URL following the question mark.
Example:
https://example.com/?p=123
The query string is:
p=123
Multiple parameters can be separated with an ampersand:
https://example.com/?page=2&sort=price
Query strings are normal.
Their existence does not mean a website is hacked.
The issue begins when malware generates large numbers of meaningless or spam-producing variations.
Why ?p= can be legitimate
WordPress uses the p parameter to request a post by its database ID.
For example:
https://example.com/?p=123
If post ID 123 exists, WordPress may redirect the request to its normal permalink:
https://example.com/example-post/
Therefore, this rule would be unsafe:
RewriteCond %{QUERY_STRING} (^|&)p=[0-9]+(&|$) [NC]
RewriteRule ^ - [G,L]
It would block every WordPress post query URL.
Even when the site uses pretty permalinks, old links or external sites may still reference the ?p= version.
How ?p= was abused in the incident
The hacked website produced many URLs in a narrow numerical range:
/?p=23981
/?p=23932
/?p=23919
/?p=23916
/?p=23860
/?p=23795
/?p=23783
Before blocking the range, I checked whether those IDs belonged to real posts.
A database query can help:
SELECT ID, post_title, post_type, post_status
FROM wp_posts
WHERE ID BETWEEN 23000 AND 24999
ORDER BY ID ASC;
If legitimate records exist, a broad range rule may not be appropriate.
You may need to:
- Exclude known IDs
- Match another part of the request
- Handle the response in PHP
- Use a smaller numerical range
- Avoid blocking the pattern entirely
?q= may also be legitimate
The q parameter is not WordPress’s default search parameter, but plugins and custom applications may use it.
Examples include:
- Search plugins
- Product filters
- Documentation systems
- External integrations
- Analytics tools
Never assume ?q= is malicious simply because it appears in spam examples.
Verify:
- Does the legitimate site use it?
- What response does a normal
?q=request produce? - Is the suspicious value always numerical?
- Does the spam use a consistent length or range?
- Do logs show a common user agent or referrer?
- Does the pattern appear in Google Search Console?
?a= and other single-letter parameters
Attackers sometimes generate many variants by changing the parameter name:
/?a=839472938
/?b=739284729
/?x=193847293
A tempting firewall rule is:
RewriteCond %{QUERY_STRING} (^|&)[a-z]=[0-9]+(&|$) [NC]
RewriteRule ^ - [G,L]
That is extremely broad.
It may block legitimate features that use parameters like:
?s=wordpress
?p=123
?m=202607
Pattern matching should be evidence-driven.
Collect URLs before blocking them
I combined URLs from several sources:
- Google Search Console Pages report
- Search performance exports
- Server access logs
- Google search results
- Malicious sitemap files
- Security scanner reports
The URLs were saved in a CSV file for analysis.
A simple CSV might look like:
URL
https://example.com/?a=839472938
https://example.com/?q=739284729
https://example.com/?p=23932
https://example.com/real-page/
Filter the domain with Python
import pandas as pd
INPUT_FILE = "urls.csv"
OUTPUT_FILE = "domain-urls.csv"
SITE_URL = "https://example.com"
df = pd.read_csv(INPUT_FILE)
if "URL" not in df.columns:
raise ValueError("The CSV must contain a column named URL.")
urls = df["URL"].fillna("").astype(str)
domain_urls = df[urls.str.startswith(SITE_URL)].copy()
domain_urls.to_csv(OUTPUT_FILE, index=False)
print(f"Saved {len(domain_urls)} URLs to {OUTPUT_FILE}")
Extract query parameters
Python’s standard library can separate parameter names and values safely:
import pandas as pd
from urllib.parse import urlparse, parse_qs
INPUT_FILE = "domain-urls.csv"
OUTPUT_FILE = "query-parameters.csv"
df = pd.read_csv(INPUT_FILE)
rows = []
for url in df["URL"].fillna("").astype(str):
parsed = urlparse(url)
parameters = parse_qs(parsed.query, keep_blank_values=True)
for name, values in parameters.items():
for value in values:
rows.append(
{
"url": url,
"path": parsed.path,
"parameter": name,
"value": value,
"value_length": len(value),
"numeric": value.isdigit(),
}
)
result = pd.DataFrame(rows)
result.to_csv(OUTPUT_FILE, index=False)
print(f"Saved {len(result)} parameter rows to {OUTPUT_FILE}")
This makes it easier to answer questions such as:
- Which parameter appears most often?
- Are the values always numbers?
- What is the usual value length?
- Are the values concentrated in one range?
- Do different parameters generate the same content?
Count the patterns
import pandas as pd
df = pd.read_csv("query-parameters.csv")
summary = (
df.groupby(["parameter", "numeric"])
.size()
.reset_index(name="count")
.sort_values("count", ascending=False)
)
print(summary.to_string(index=False))
Example output:
parameter numeric count
a True 8421
q True 6138
p True 1820
s False 42
This does not prove that a, q, or p are malicious.
It identifies where to investigate.
Review the server response
Test several examples:
curl -I "https://example.com/?a=839472938"
curl -I "https://example.com/?q=739284729"
curl -I "https://example.com/?p=23932"
Check whether they return:
- 200
- 301
- 302
- 404
- 410
- 500
A hacked URL returning 200 is especially important because Google may treat it as a real page.
Also inspect the body:
curl -s "https://example.com/?a=839472938" | head -n 40
Compare normal and suspicious requests.
Check access logs
Useful questions include:
- Is Googlebot requesting the URLs?
- Are other bots requesting the same patterns?
- How often are they requested?
- Do they reach
index.php? - Are they producing expensive application responses?
- Did the requests continue after malware removal?
Example Apache log searches:
grep -E '\?(a|q|p)=[0-9]+' access.log | head
Count matching requests:
grep -Ec '\?(a|q|p)=[0-9]+' access.log
View the most common URLs:
grep -E '\?(a|q|p)=[0-9]+' access.log \
| awk '{print $7}' \
| sort \
| uniq -c \
| sort -nr \
| head -n 30
Log formats differ, so the URL may not always be in field seven.
Build the narrowest possible rule
Suppose the investigation confirms:
-
aandqare not used legitimately - Their spam values always contain at least eight digits
- Requests should never return content again
A narrow Apache rule could be:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{QUERY_STRING} (^|&)(?:a|q)=[0-9]{8,}(&|$) [NC]
RewriteRule ^ - [G,L]
</IfModule>
This returns 410 Gone.
It does not block:
?s=wordpress
?p=123
?q=shoes
?a=42
Whether those exclusions are correct depends on the actual website.
Handle a confirmed ?p= range separately
If a numerical range has been verified as malicious and contains no legitimate WordPress records:
# Example range only: p=23000 through p=24999
RewriteCond %{QUERY_STRING} (^|&)p=2[3-4][0-9]{3}(&|$) [NC]
RewriteRule ^ - [G,L]
This is safer than blocking every post ID, but it still needs testing.
Test both malicious and legitimate cases
Spam tests:
curl -I "https://example.com/?a=12345678"
curl -I "https://example.com/?q=987654321"
curl -I "https://example.com/?p=23932"
Legitimate tests:
curl -I "https://example.com/?s=security"
curl -I "https://example.com/?p=123"
curl -I "https://example.com/"
curl -I "https://example.com/wp-login.php"
A successful spam response should look like:
HTTP/2 410
The legitimate URLs should keep their expected behaviour.
Why server-level handling matters
If WordPress handles every request, even a missing page can require:
- PHP startup
- WordPress bootstrap
- Plugin loading
- Database queries
- Theme rendering
A narrow Apache rule can stop confirmed spam requests earlier.
That reduces unnecessary application work while also providing a clear status to search engines.
Do not treat query patterns as malware signatures
A URL such as:
/?q=12345678
is not automatically proof of malware.
Context matters.
The evidence should include some combination of:
- Unwanted content
- Unexpected HTTP 200 responses
- Search Console impressions
- Large-scale repeated patterns
- Malicious files or database content
- Access-log activity
- Unknown sitemap entries
The rule should be the result of the investigation, not the beginning of it.
Final checklist
Before blocking query-string spam:
- Export representative URLs
- Group them by parameter and structure
- Confirm whether the parameter is used legitimately
- Check database IDs for
?p= - Test current HTTP responses
- Inspect server logs
- Create the narrowest rule possible
- Test legitimate pages after deployment
- Monitor 410 responses
- Keep the patterns out of the canonical sitemap
The larger lesson is simple:
Do not block thousands of URLs individually when a small number of verified patterns explains them.
The full cleanup—including malware removal, Google Search Console requests, 410 handling, and sitemap recovery—is available in my complete WordPress SEO spam case study.
Top comments (0)