Using Burp Python Scripts to sign requests with RSA keys

In this article, I would like to describe the API request security mechanism that I encountered in one of my projects. This mechanism uses RSA keys to verify the integrity of requests and also incorporates an additional security feature against replay attacks.

Using Burp Python Scripts to sign requests with RSA keys

Problem Description

In the mentioned project, the API was used for communication, among other things, with a mobile application. The mobile application sends sensitive data, such as personal information and credit card details, to the backend. For obvious reasons, these data must be protected against tampering. The implemented mechanism aims to hinder attackers from manipulating requests and further exploitation. At the same time, the applied security measures make it challenging to conduct penetration testing. In the examined software, the author implemented two security mechanisms. Firstly, in the requests, two additional headers can be observed: X-Nonce-Value and X-Nonce-Created-At, designed to protect against replay attacks. Secondly, there is a third header, X-Signature, which ensures the integrity of the transmitted message through an RSA signature.

To address the problem and conduct security testing, my initial consideration was to use the library provided by the client (intended to facilitate testing). However, this solution had certain drawbacks. Firstly, it required frequent changes to the library code, which could be time-consuming. Secondly, it limited the number and scope of tests I could perform.

Ultimately, I opted for a solution involving the use of an additional Burp plugin called Python Scripter (more about the plugin can be found here, and additional usage examples can be found here). I also wrote my own script, which I will present in the later part of the article.

Description of the Applied Security Mechanism

The presented request security mechanism operates as follows:

  1. The application adds a unique nonce value to the request using uuid4 (header X-Nonce-Value).
  2. The application adds the current date to the request (header X-Nonce-Created-At).
  3. The mobile application generates a digital signature for the request using the private key, the message body, the nonce value, and the current date.
  4. The application adds the digital signature to the request (header X-Signature).
  5. The API server verifies the digital signature using the public key.
  6. If the digital signature is valid, the API server accepts the request. If the digital signature is invalid, the API server rejects the request.

Implementation

Before presenting a comprehensive solution to the previously described problem, let’s start with something simple. The following piece of code updates the X-Nonce-Value header with each request sent. Not without reason, I begin with a random value for the nonce header; this script can be useful in any penetration testing scenarios where we want to trace the execution of our requests. For example, we can run Burp Scanner and aim to find a request triggering a vulnerability in the logs or trace the execution of our request in the application logs. A unique value for the header will significantly ease our task.

import uuid

NONCE_HEADER = 'X-Nonce-Value'

if messageIsRequest:
    requestInfo = helpers.analyzeRequest(messageInfo.getRequest())
    headers = requestInfo.getHeaders()
    requestBody = messageInfo.getRequest()[requestInfo.getBodyOffset():]

    for h in headers:
        if header.startswith(NONCE_HEADER):
            headers.remove(h)

    nonce_value = str(uuid.uuid4())
    nonce_value = '{}: {}'.format(NONCE_HEADER, nonce_value)
    print('Adding new', nonce_value)
    headers.append(nonce_value)
    
    request = helpers.buildHttpMessage(headers, requestBody)
    messageInfo.setRequest(request)

In the next step, we will add a timestamp and some debug information to the script.

import uuid
import datetime

NONCE_HEADER = 'X-Nonce-Value'
NONCE_CREATED_AT_HEADER = 'X-Nonce-Created-At'

if messageIsRequest:
    requestInfo = helpers.analyzeRequest(messageInfo.getRequest())
    headers = requestInfo.getHeaders()
    requestBody = messageInfo.getRequest()[requestInfo.getBodyOffset():]

    newHeaders = []
    for h in headers:
        if NONCE_HEADER not in h and NONCE_CREATED_AT_HEADER not in h:
            newHeaders.append(h)
        else:
            print('Header exist, removing: ', h)

    nonce_value = str(uuid.uuid4())
    nonce_created_at = '{}+00:00'.format(datetime.datetime.utcnow().isoformat())

    nonce_value = '{}: {}'.format(NONCE_HEADER, nonce_value)
    print('Adding new', nonce_value)
    headers.append(nonce_value)
    
    nonce_created_at = '{}: {}'.format(NONCE_CREATED_AT_HEADER, nonce_created_at)
    print('Adding new', nonce_created_at)
    newHeaders.append(nonce_created_at)

    request = helpers.buildHttpMessage(headers, requestBody)
    messageInfo.setRequest(request)

Now that we have implemented the two basic headers, it’s worth taking a closer look at the signature itself. Currently, Burp Python Scripts don’t have a straightforward way to add additional libraries. Another limitation is that we only have access to Python 2.7, which, as we know, is no longer supported. To work around these limitations, we can use system calls through the subprocess library. For example:

import subprocess

process = subprocess.Popen("<cmd>”,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        shell=True
)

output, err = process.communicate()
if err.decode() != "":
    raise Exception(err)

This will allow us to execute any command in the system using the Python language. At this stage, we have almost all the essential elements, and we only need information on how to construct the signature input. Here, I encountered another nuance. Typically, when working with Burp and Repeater software, we use the Pretty mode for request presentation. This is a fairly common and understandable practice, as it makes our requests neatly formatted and is the default setting in Burp. The issue with the default display in the Pretty tab is that it doesn’t show white spaces. Therefore, when signing and editing requests, it’s advisable to use the RAW tab to remove all unnecessary white spaces, which may be considered during the signature generation. Additionally, to pass the entire signature input to the system without worrying about extra spaces in the message body, it needs to be encoded using base64. The code building the signature input for the described case will look as follows:

msg = helpers.bytesToString(requestBody)
signature_input = "{}{}{}{}{}".format(method, path, nonce_value, nonce_created_at, msg)
signature_input_b64 = base64.standard_b64encode(signature_input.encode()).decode()

The attentive reader might notice at this point that instead of worrying about additional white spaces in the message, we could deserialize the JSON message and then serialize it again. However, a new problem arises here. There is a difference between the JSON serialization in Python 2.7 and Python 3. The most significant difference is that the order of keys in the serialized dictionary changes. This causes the component on the other side, verifying our signature, to have a different signature input, and the entire verification operation will fail. You can read more about the differences in JSON deserialization between Python 2.7 and Python 3 here.

Below is the Python code that implements the described request signing mechanism:

import uuid
import datetime
import base64
import subprocess

PRIVATE_KEY = "private.key"
SIGNATURE_HEADER = 'X-Signature'
NONCE_HEADER = 'X-Nonce-Value'
NONCE_CREATED_AT_HEADER = 'X-Nonce-Created-At'


if messageIsRequest:
    requestInfo = helpers.analyzeRequest(messageInfo.getRequest())
    headers = requestInfo.getHeaders()
    requestBody = messageInfo.getRequest()[requestInfo.getBodyOffset():]
    path = messageInfo.getUrl().getPath()
    method = requestInfo.getMethod()

    newHeaders = []
    for h in headers:
        if SIGNATURE_HEADER not in h and NONCE_HEADER not in h and NONCE_CREATED_AT_HEADER not in h:
            newHeaders.append(h)
        else:
            print('Header exist, removing: ', h)

    nonce_value = str(uuid.uuid4())
    nonce_created_at = '{}+00:00'.format(datetime.datetime.utcnow().isoformat())

    msg = helpers.bytesToString(requestBody)
    signature_input = "{}{}{}{}{}".format(method, path, nonce_value, nonce_created_at, msg)
    print('signature_input', signature_input)
    signature_input_b64 = base64.standard_b64encode(signature_input.encode()).decode()
    print('signature_input_b64', signature_input_b64)

    cmd = """printf %s "{}" | openssl dgst -sha256 -sign {}| openssl base64""".format(signature_input_b64, PRIVATE_KEY)
    print(cmd)
    process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)

    output, err = process.communicate()
    if err.decode() != "":
        raise Exception(err)

    signature = output.decode().replace("\n", "")
    new_sign = '{}: {}'.format(SIGNATURE_HEADER, signature)
    print('Adding new', new_sign)
    newHeaders.append(new_sign)


    nonce_value = '{}: {}'.format(NONCE_HEADER, nonce_value)
    print('Adding new', nonce_value)
    newHeaders.append(nonce_value)

    nonce_created_at = '{}: {}'.format(NONCE_CREATED_AT_HEADER, nonce_created_at)
    print('Adding new', nonce_created_at)
    newHeaders.append(nonce_created_at)

    request = helpers.buildHttpMessage(newHeaders, requestBody)

    messageInfo.setRequest(request)

In Figure 1, an attempt to send a request without additional headers is illustrated. As can be observed, the server indicated their absence and did not process the message.

Fig. 1. Sent request and received response, lacking the required additional security headers.
Fig. 1. Sent request and received response, lacking the required additional security headers.

In Figure 2, another attempt to send a request is depicted, this time with the required headers added. However, the change in the message body caused a lack of compatibility with the signature, resulting in the server rejecting the message as well.

Fig. 2. Sent request and received response, lack of compatibility with the message signature.
Fig. 2. Sent request and received response, lack of compatibility with the message signature.

In Figure 3, the use of the written code is illustrated. The headers X-Nonce and X-Signature are correct, as evidenced by the valid response received from the server.

Fig. 3. Sent request and received response, message signed correctly.
Fig 3. Sent request and received response, message signed correctly.

In Figure 4, a modified request logged by the Burp Logger module is presented, which was sent to the server. The difference from Figure 3 lies in the values of the headers X-Signature, X-Nonce-Value, and X-Nonce-Created-At. When modifying the message body, there is no longer a need to concern oneself with the correct values of these headers; they will be automatically adjusted before being sent to the server.

Fig 4. Sent request and received message logged in the Logger module.
Fig. 4. Sent request and received message logged in the Logger module.

Summary

The mechanism of using nonce and signing requests can be an effective way to protect API requests from tampering. Without additional information provided by the application’s author, it would be challenging to discern how the implemented digital signature is constructed. The code snippet I presented significantly facilitates further API testing since there is no need to focus on ensuring the correctness of all header values.

This article also aims to introduce alternative mechanisms for securing the integration of transmitted requests, other than HMAC, and a possible approach to testing them. If someone is interested in checking the plugin’s functionality, they can find a simple server code under this link, which implements the verification of the applied signature.

In the next article, I will present another communication security mechanism that I encountered in a project. Like the one discussed in this article, its purpose is to impede further exploitation of the system by attackers, and I must admit that it can do so remarkably effectively.

References

  1. https://en.wikipedia.org/wiki/Replay_attack
  2. https://www.zimuel.it/blog/sign-and-verify-a-file-using-openssl
  3. https://sereysethy.github.io/encryption/2017/10/23/encryption-decryption.html
  4. https://learn.microsoft.com/pl-pl/azure/communication-services/tutorials/hmac-header-tutorial
  5. https://httpwg.org/http-extensions/draft-ietf-httpbis-message-signatures.html#name-rsassa-pkcs1-v1_5-using-sha
  6. https://github.com/PortSwigger/python-scripter
  7. https://github.com/lanmaster53/pyscripter-er/tree/master/snippets
  8. https://github.com/mwalkowski/api-request-security-poc/
  9. https://stackoverflow.com/questions/51769239/why-json-dumps-in-python-3-return-a-different-value-of-python-2