## [Insomni'hack CTF 2024-Writeup web](https://) ### [InsoBank](https://) - **Title : NextJs, Python, MySql, postgresSql, smart from u :3** - The web application in this challenge is a banking app that supports transferring deposits from one account to another. However, all the accounts involved in a transfer operation must belong to the same person; this means we cannot transfer money from our account to other people’s accounts. ![image](https://hackmd.io/_uploads/SkBCrl9jT.png) - You must register with new account and login with your account ![image](https://hackmd.io/_uploads/SyDM8x5sp.png) - You only can transfer for 2 account provide by sever - This is UI transfer ![image](https://hackmd.io/_uploads/S1hdIl9j6.png) - To have flag FLAG = os.getenv("FLAG") or 'INS{fake-flag}' - Your acoount must have 1 account have balance > 13.37. ``` for (accountid,name,balance) in cursor.fetchall(): if balance > 13.37: results[accountid] = {'name': name, 'balance': balance, 'flag': FLAG} else: results[accountid] = {'name': name, 'balance': balance} conn.close() return jsonify(results) ``` - View important source: [Route /batch/new](https://) ``` @app.route("/batch/new", methods=['POST']) @jwt_required() def newbatch(): userid = get_jwt_identity() batchid = str(uuid.uuid4()) senderid = request.json.get("senderid") conn = get_db() cursor = conn.cursor() cursor.execute(''' SELECT userid FROM accounts WHERE id = %s ''',(senderid,)) data = cursor.fetchone() if data == None or data[0]!=userid: return jsonify({"error":"Invalid account"}) cursor.execute(''' INSERT INTO batches(id,senderid,userid) VALUES (%s,%s,%s) ''', (batchid,senderid,userid)) conn.commit() conn.close() return redirect("/batches") ``` - This code effective touch a new batch for tranfer. [Route /validate](https://) ``` @app.route("/validate", methods=['POST']) @jwt_required() def validate(): userid = get_jwt_identity() batchid = request.json.get("batchid") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id,senderid FROM batches WHERE id = %s AND userid = %s", (batchid,userid)) data = cursor.fetchone() if data == None or data[0] != batchid: return jsonify({"error":"Invalid batchid"}) senderid = data[1] cursor.execute("LOCK TABLES batch_transactions WRITE, accounts WRITE, batches WRITE") cursor.execute("SELECT sum(amount) FROM batch_transactions WHERE batchid = %s", (batchid,)) data = cursor.fetchone() if data == None or data[0] == None: cursor.execute("UNLOCK TABLES") conn.close() return jsonify({"error":"Invalid batch"}) total = data[0] cursor.execute(''' SELECT balance FROM accounts WHERE id = %s ''', (senderid,)) data = cursor.fetchone() balance = data[0] if data else 0 if total > balance: cursor.execute("UNLOCK TABLES") conn.close() return jsonify({"error":"Insufficient balance ("+str(total)+" > " + str(balance) +")"}) cursor.execute(''' UPDATE accounts SET balance = (balance - %s) WHERE id = %s ''',(total,senderid)) cursor.execute(''' UPDATE batch_transactions SET verified = true WHERE batchid = %s; ''',(batchid,)) connpg = get_db(type='pg') cursorpg = connpg.cursor() cursorpg.execute(''' UPDATE batch_transactions SET verified = true WHERE batchid = %s ''',(batchid,)) connpg.commit() connpg.close() cursor.execute(''' UPDATE batches SET verified = true WHERE id = %s; ''',(batchid,)) cursor.execute(''' UNLOCK TABLES; ''') conn.close() return redirect("/batches") ``` - [Route /transfer](https://) ``` @app.route("/transfer", methods=['POST']) @jwt_required() def transfer(): userid = get_jwt_identity() txid = str(uuid.uuid4()) amount = request.json.get('amount') recipient = request.json.get('recipient') batchid = request.json.get('batchid') if not float(amount) > 0: return jsonify({"error":"Invalid amount"}) conn = get_db() cursor = conn.cursor() cursor.execute(''' SELECT count(*) FROM batches WHERE id = %s AND userid = %s AND verified = false ''',(batchid,userid)) data = cursor.fetchone() if data[0] != 1: conn.close() return jsonify({"error":"Invalid batchid"}) cursor.execute(''' SELECT userid FROM accounts WHERE id = %s ''', (recipient,)) data = cursor.fetchone() if data == None or data[0] != userid: conn.close() return jsonify({"error": "Recipient account does not belong to you"}) cursor.execute(''' SELECT count(*) FROM batch_transactions WHERE batchid = %s AND recipient = %s ''',(batchid,recipient)) data = cursor.fetchone() if data[0] > 0: conn.close() return jsonify({"error":"You can only have one transfer per recipient in a batch"}) cursor.execute(''' SELECT balance FROM accounts WHERE id = (SELECT senderid FROM batches WHERE id = %s) ''', (batchid,)) data = cursor.fetchone() balance = data[0] connpg = get_db(type='pg') cursorpg = connpg.cursor() cursorpg.execute(''' LOCK TABLE batch_transactions; INSERT INTO batch_transactions (id,batchid,recipient,amount) SELECT %s,%s,%s,%s WHERE (SELECT coalesce(sum(amount),0)+%s FROM batch_transactions WHERE batchid = %s) <= %s ''', (txid,batchid,recipient,amount,amount,batchid,balance)) connpg.commit() connpg.close() cursor.execute(''' INSERT INTO batch_transactions (id,batchid,recipient,amount) SELECT %s,%s,%s,%s WHERE (SELECT coalesce(sum(amount),0)+%s FROM batch_transactions WHERE batchid = %s) <= %s ''', (txid,batchid,recipient,amount,amount,batchid,balance)) conn.commit() conn.close() return redirect("/transactions?batchid="+batchid) ``` - will first check the user's jwt, then check if the amount the user sent is greater than 0 or not. - then it will select the total number of records with unverified transaction from mysql , Then it will check the batch id, user id and see if the verified attribute is false or not (which means the transaction has not been committed) . If batch id does not exist, user id is not equal to our id, an error will be returned. - Then it will check if 1 id (there are 3 ids for each recipient, like 1 account with 3 wallets) received the money twice or not. If so, an error will also be returned. The next part will take our balance and add information to both the MySQL and PostgreSQL databases. ## After a thorough code review, here are the vulnerabilities that we can exploit: - **Discrepancies in Data Precision:** - In the MySQL database, the data type of amount is decimal(10,2), while in the PostgreSQL database, it is just decimal. This causes a floating-point number like 0.014 to be stored in MySQL as 0.01, but as 0.014 in PostgreSQL. ![image](https://hackmd.io/_uploads/H17sue5oa.png) - We can see excec_transfer will be excute every 1 minute by sh ![image](https://hackmd.io/_uploads/H17wtxcja.png) ``` #!/usr/local/bin/python import psycopg2 import mysql.connector import sqlite3 import os MYSQL_DB_HOST = os.getenv("MYSQL_HOST") or 'mysql' MYSQL_DB_USER = os.getenv("MYSQL_USER") or 'user' MYSQL_DB_PASSWORD = os.getenv("MYSQL_PASSWORD") or 'password' MYSQL_DB_DATABASE = os.getenv("MYSQL_DB") or 'inso24' PG_DB_HOST = os.getenv("PG_HOST") or 'pg' PG_DB_USER = os.getenv("PG_USER") or 'postgres' PG_DB_PASSWORD = os.getenv("PG_PASSWORD") or 'postgres' PG_DB_DATABASE = os.getenv("PG_DB") or 'inso24' def get_db(type='mysql'): if type == 'mysql': conn = mysql.connector.connect( host=MYSQL_DB_HOST, user=MYSQL_DB_USER, password=MYSQL_DB_PASSWORD, database=MYSQL_DB_DATABASE ) elif type == 'sqlite': conn = sqlite3.connect("/app/db/db.sqlite") elif type == 'pg': conn = psycopg2.connect( host=PG_DB_HOST, database=PG_DB_DATABASE, user=PG_DB_USER, password=PG_DB_PASSWORD) return conn conn = get_db() cursor = conn.cursor() connpg = get_db(type='pg') cursorpg = connpg.cursor() cursorpg.execute(''' SELECT DISTINCT batchid FROM batch_transactions WHERE verified = true and executed = false ''') for (batchid,) in cursorpg.fetchall(): TRANSFERS = {} cursorpg.execute(''' SELECT id,sender,recipient,amount FROM batch_transactions WHERE batchid = %s AND verified = true AND executed = false ''',(batchid,)) transactions = cursorpg.fetchall() for (txid,sender,recipient,amount) in transactions: cursor.execute(''' UPDATE batch_transactions SET executed = true WHERE id = %s ''',(txid,)) cursorpg.execute(''' UPDATE batch_transactions SET executed = true WHERE id = %s ''',(txid,)) TRANSFERS[recipient] = amount if recipient not in TRANSFERS.keys() else TRANSFERS[recipient] + amount for recipient in TRANSFERS: cursor.execute(''' UPDATE accounts SET balance = balance + %s WHERE id = %s ''', (TRANSFERS[recipient], recipient)) cursor.execute(''' UPDATE batches SET executed = true WHERE id = %s ''',(batchid,)) connpg.commit() conn.commit() connpg.close() conn.close() ``` Flow exec : - This section will check for transactions that have not yet been executed and have been verified to perform the money transfer, then repeat a number of times equal to the total number of records, then each iteration finds the number of similar records and repeats with the number of records. selected, set excuted = true for both mysql and pgsql tables to confirm excuted this time, then assign again TRANSFERS[recipient] = amount if recipient not in TRANSFERS.keys() else TRANSFERS[recipient] + amount, continue looping with the number of elements of TRANSFER then add balance to the value of TRANSFERS[recipient] , so This will be an important part to get the FLAG. - The logic flaw arises here. The amount to deduct is based on decimal(10,2), while the amount to increase is based on decimal. When two transactions with the same batch_id are processed at once, an extra $0.01 gets smuggled into the recipient’s account. - For example, if two transactions are each transferring $0.014 from saving to checking account, the sender’s account gets deducted by $0.02, but the recipient’s account gets increased by $0.028, which rounds up to $0.03. [deployment](https://) - There are many ways to solve it : race or take advantage of differences. - Sending a request for the amount 09.0000000000000000000000000000000000000000000000000000000000000000000 may cause MySQL to return an error, but not PostgreSQL. - ![image](https://hackmd.io/_uploads/r1NAEpjjT.png) - ![image](https://hackmd.io/_uploads/SJhAEaijT.png) Thank you @null001 for helping me understand the flow:3