Article Image
read

The challenge was described as follows, and a single APK file was attached.

We have developed a smart solution for storing your images. Key features:
Store images
Synchronization between devices
Soon:
Secure image sharing

Stage 1: Getting the Code

Since Android APK files are simple ZIP archives, we can easily decompress them and have a look at what's inside. A brief investigation of the resources inside the package doesn't reveal anything out of the ordinary, it seems like we're dealing with a standard Android application written in Kotlin, as no native libraries were found.

Using dex2jar we can convert the classes.dex file to a .jar format, which we can later decompile with the Java Decompiler.

This gives us access to the (unfortunately) minified version of the code. While most of the symbols - such as method and variable names - are lost, we can quickly find the namespace containing all of the encryption/decryption code, and the web API interface.

Since I couldn't find any anti-tampering code (such as root or simulator detection), I decided to install the app in a simulator and see it in action.

Stage 2: Understanding the API

Now that we have the app running and can easily capture any requests being made (made even easier since the communication is happening over HTTP) I started writing my own API client in Python to be able to easily mock any request we might need.

The base URL for the API is http://re-privategallery.ctfz.one/ and it seems like at least 4 endpoints are available:

  • GET /users/login
  • GET /image/list?user_id={user_id}
  • GET /image/{image_id}?user_id={user_id}&from_ts={timestamp}&to_ts={timestamp}
  • POST /image/upload?user_id={user_id}&enc_key={enc_key}&name={name}

After sending a request to /users/login with any credentials passed via basic auth HTTP headers, the API will reply with:

{
  "login": "maku",
  "user_id": "416e24a3-6b90-4609-a8e9-0bb0068ce5cb"
}

All other endpoints will require the user_id value for authorization purposes, and accept it as a query parameter - upon discovering that, I've decided to check whether the API enforces that the user_id belongs to the user sending the request. I created a second account in the service and by passing in the user_id of the first account I was able to receive the list of the images while still authenticating as user #2.

With this in mind, I thought I understood how the task is supposed to be solved:

  1. Get the admin user_id
  2. Find a flaw in the encryption mechanism
  3. Get the encrypted data from the api and somehow decrypt the flag.

To make sure that was the case, I've decided to focus on the encryption part first - after all, if I couldn't find the flaw in there, I wouldn't be able to decrypt admin's images.

Stage 4: Understanding the encryption

After some investigation, I was able to understand the image encryption process in detail. The keys are generated using the PBKDF2WithHmacSHA1 method, and the authors of the task were nice enough to hardcode both the password and the IV in the app.

First, a "Level 1" (L1) key is created using the full HTTP Authorization header value, i.e Basic TmV2ZXJHb25uYTpHaXZlWW91VXA=. This key will be unique for every user, but constant between all of their images.

Then, a "Level 2" (L2) key is generated on a per-image basis. This key is generated from data obtained from calling Java's Random.nextBytes() method. To achieve "TRUE RANDOMNESS" the RNG is seeded with a timestamp of when the image was captured.

Finally, the image is being encrypted using the L2 key, and the L2 key itself is encrypted using the L1 key. Both "secure" values are now encoded with base64, and sent to the server using the /images/upload endpoint.

This method is (luckily for us) flawed, because by knowing the timestamp that was used to seed the random number generator we can recreate the L2 key, and decrypt the image without ever having to decrypt the enc_key itself.

To get the image timestamp, we can abuse the /images/list endpoint: it lets us filter by a timestamp range, so using a simple binary search, we can find at which point the image was created (as client will send that information alongside the encrypted data).

I wrote a quick and dirty brute-forcer and tested it on one of the images I have uploaded. After a while (the servers were quite unstable at that point), it happily returned the exact timestamp the app used to generate the L2 key.

def brute(start, end):
    delta = end - start
    mid = int(start + delta / 2)

    if delta == 0:
        return start

    images_left = api(
        "images/list",
        {
            "user_id": USER_ID,
            "from_ts": start,
            "to_ts": mid
        }
    )

    images_right = api(
        "images/list",
        {
            "user_id": USER_ID,
            "from_ts": mid + 1,
            "to_ts": end
        }
    )


    if len(images_left) > 0:
        brute(start, mid)
    if len(images_right) > 0:
        brute(mid+1, end)

Now that we have confirmed that an image's key can be easily regenerated just by knowing the corresponding user_id, we need to somehow get the remaining missing pieces.

Stage 5: Getting stuck

Embarrassingly, this part of the challenge gave me the most trouble. I spent a lot of time looking around and creating accounts left and right to try and find any relation between the provided data and the resulting user_id.

After almost giving up on the challenge, I realized that I haven't tried the simplest thing - checking /users/list

This is what I saw after hitting the endpoint:

[
  {
    "login": "maku",
    "user_id": "6ff2ac4f-9e3c-48c6-9f2f-a21c3c6010b8"
  },
  {
    "login": "CTFZoneFlagMaker",
    "user_id": "9ebda904-14ea-44bb-869b-f856c7774d97"
  }
]

We're in.

Stage 6: Tying it all together.

We confirm that the admin of the challenge has some photos uploaded by checking /images/list:

[
  {
    "data": null,
    "image_id": "e7ae0b38-ed83-4ea5-84c1-77409c31295a",
    "user_id": "9ebda904-14ea-44bb-869b-f856c7774d97",
    "enc_key": "k1dX8NXTa23iLV9KTWDT0hcM5dqibaJYaewvsiUJADU3ZZY5mFnwOpV6ko7Brrkc"
  }
]

I ran my brute-forcer and quickly found the timestamp and were able to generate a valid decryption key by simply copy-pasting the code from the decompiled sources to make sure we get the same result:

fun generate(paramLong: Long): SecretKey {
    val random = Random()
    random.setSeed(paramLong)
    val arrayOfByte = ByteArray(32)
    random.nextBytes(arrayOfByte)
    return SecretKeySpec(arrayOfByte, "AES")
}

With the key in hand, the last step was to actually decrypt the data: