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 of the contract should be able to access it. Contract: here
cat AccessControl.solSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Read AccessControl.sol and find the exposed function
ObservationThe contract source is provided and the challenge says only the owner reaches the flag. Look for a public function that writes the owner variable without checking who is calling.Open the source and look for any externally callable function that writes toowner. In this contract there's a publicchangeOwner(address)with no msg.sender check, so anyone can set themselves as owner. Note that taking ownership is only step one: the flag is gated behind a separatesolve()call that requiresmsg.sender == owner, andgetFlag()reverts untilsolve()has flipped therevealedflag. See smart contract CTF bugs for the full taxonomy of access control flaws.bashcat AccessControl.solWhat didn't work first
Tried: Assume the contract stores the flag in a public state variable and try to read it with cast call getFlag() immediately without checking ownership.
getFlag requires the revealed boolean and reverts immediately without it. The flag is not sitting in unguarded storage: it is behind a boolean only solve() flips, and solve() requires you to be the owner. Skip the chain and every read reverts.
Tried: Search for a tx.origin bypass by deploying an intermediary contract and routing the changeOwner call through it.
changeOwner checks nothing: no msg.sender guard to bypass, no tx.origin check to trick. A direct send with your address is enough. An intermediary contract adds complexity and gas for nothing.
Learn more
Access control in Solidity smart contracts relies on the developer correctly checking who is calling a function. The canonical pattern is an
onlyOwnermodifier that comparesmsg.senderto a storedowneraddress. When this check is missing, broken, or bypassable, any caller can claim ownership.The four flaws to scan for in a CTF contract:
- The owner variable is public and set in a function anyone can call (this challenge).
tx.originis used instead ofmsg.sender.tx.originis always the EOA that signed the outermost transaction, so a victim calling an attacker contract still authenticates as the victim.- The
initialize()function lacks a flag preventing re-initialization, letting anyone replay it after deployment. - A pre-0.5 contract uses a same-named function as a constructor (typo or deliberate), making it permanently callable.
The Foundry toolkit's
castcommand-line tool is invaluable for interacting with deployed contracts without writing a full script.cast callreads state,cast sendsends transactions. Read the Solidity source first, always.Step 2Become the owner
ObservationchangeOwner is public with no msg.sender guard at all. Call it with your own address and ownership transfers.Send changeOwner(address) with your own funded address as the argument. Replace CONTRACT, YOUR_ADDR, RPC_URL, and PRIVATE_KEY with the values from the instance page. The challenge chain typically rejects EIP-1559 fee fields, so add --legacy, and set --gas-limit 200000 because cast's gas estimation can fail against these instances.bashYOUR_ADDR=$(cast wallet address --private-key $PRIVATE_KEY)bashcast send $CONTRACT 'changeOwner(address)' $YOUR_ADDR --rpc-url $RPC_URL --private-key $PRIVATE_KEY --legacy --gas-limit 200000What didn't work first
Tried: Omit --legacy and let cast use EIP-1559 fee fields (maxFeePerGas / maxPriorityFeePerGas) on the challenge chain.
The challenge chain does not support EIP-1559 and either errors out or silently drops the transaction. --legacy sends a Type-0 transaction the chain accepts. Without it, the send looks fine locally and never gets mined.
Tried: Call cast send without providing $YOUR_ADDR as the argument, relying on msg.sender being picked up automatically.
changeOwner takes an address parameter and stores exactly what you pass; it never reads msg.sender. Call it with no argument and cast fails on ABI encoding. Pass your own address.
Learn more
In Ethereum, all state-changing contract calls are transactions that must be signed by a private key. Here
changeOwner(address _newOwner)writesowner = _newOwnerwith no guard, so you pass your own address as the argument and the contract records you as owner. (Contrast with theowner = msg.senderpattern, where you would call a no-argument function instead.)The Uninitialized Storage Pattern is a related vulnerability: if a proxy contract's storage slot for the implementation address overlaps with the owner slot, an attacker can set the implementation to their own contract and upgrade themselves to owner. This affected several real DeFi protocols.
For CTF challenges, the challenge typically provides a funded private key (shown on the instance page). Use
cast wallet address --private-key KEYto derive your address, then sign transactions with--private-key. Always confirm state changes withcast callafter eachcast send.Step 3Verify ownership transferred
Observationsolve() reverts if ownership did not actually change, so confirm the owner slot holds your address with a free read call first.Confirm the changeOwner call actually landed before relying on solve(). cast call is free and quick. Run this right after changeOwner if solve() reverts with 'Only the owner can get the flag.'bashcast call $CONTRACT 'owner()(address)' --rpc-url $RPC_URLbash# Output should match $YOUR_ADDRLearn more
This is the cheapest sanity check available.
cast callqueries state without a transaction (no gas, instant), so use it after every state-changing call to confirm the chain saw what you expected. If the returned address doesn't match yours, yourchangeOwner()reverted silently or you sent it from the wrong key.Step 4Call solve() to reveal the flag
ObservationgetFlag reverts until a separate revealed boolean is true, and only the owner-gated solve() flips it. Call solve() as the new owner first.Now that you are the owner, call the no-argument solve() function. It checks msg.sender == owner, sets revealed = true, and emits the FlagRevealed event. This is the step that unlocks getFlag().bashcast send $CONTRACT 'solve()' --rpc-url $RPC_URL --private-key $PRIVATE_KEY --legacy --gas-limit 200000What didn't work first
Tried: Skip solve() and call getFlag() directly after becoming the owner, assuming ownership alone is sufficient to reveal the flag.
getFlag's guard checks a boolean storage variable, not ownership, and only solve() sets that boolean. Owning the contract lets you call solve(); it does not skip the call.
Tried: Read the flag from the FlagRevealed event emitted by solve() using cast tx instead of calling getFlag() afterward.
cast tx shows metadata and does not decode event logs. You would need the receipt as JSON and then to parse the logs field and decode the ABI-encoded string by hand. It works, but a plain call to getFlag is less work.
Learn more
This contract splits the win condition into two functions: an unguarded
changeOwnerthat anyone can call, and an owner-gatedsolve()that performs the privileged action. The vulnerability is entirely in the missing access check onchangeOwner;solve()itself is correctly guarded withrequire(msg.sender == owner). You satisfy that guard only because the previous step made you the owner.The flag is delivered through the
FlagRevealedevent thatsolve()emits, and is also retrievable afterward viagetFlag(). Reading transaction logs (events) is a common way CTF contracts hand back data, so inspecting thesolve()transaction receipt is an alternative to callinggetFlag().Step 5Read the flag
ObservationWith revealed set, getFlag is the only public interface that returns the string. A read-only call with the right ABI signature retrieves and decodes it.After solve() has flipped revealed to true, call getFlag() and decode the returned string. (If you skipped solve(), this reverts with 'Challenge not yet solved!'.)bashcast call $CONTRACT 'getFlag()(string)' --rpc-url $RPC_URL --from $YOUR_ADDRExpected output
picoCTF{i_c4n_b3_0wn3r_...}Learn more
cast callexecutes a read-only call (no transaction, no gas cost) against a deployed contract. The function signature format'getFlag()(string)'tells cast both the input types (none) and output types (string) so it can ABI-decode the return value for you.In production Solidity, sensitive data is rarely returned directly even to the owner - instead, events are emitted or data is stored off-chain. However, CTF contracts intentionally expose flags through a guarded function, which is why bypassing the access control directly yields the flag.
The broader lesson is that blockchain data is public: even "private" Solidity state variables can be read by anyone via
eth_getStorageAtRPC calls. True secrets should never be stored on-chain unencrypted - but in this CTF challenge the flag is gated behind an access-controlled function rather than encrypted storage, making ownership the only protection.
Interactive tools
- Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
- Number Base ConverterConvert numbers between binary, octal, decimal, and hexadecimal instantly. Enter any value and see all four bases update in real time.
Flag
Reveal flag
picoCTF{i_c4n_b3_0wn3r_...}
changeOwner(address) has no access check, so set yourself as owner, then call the owner-only solve() to reveal the flag (getFlag() reverts until solve() runs). Use --legacy --gas-limit 200000 on the cast send calls.
Key takeaway
How to prevent this
How to prevent this
Smart contract access control is mostly about not trusting tx.origin and not leaving init functions reachable.
- Use OpenZeppelin's
OwnableorAccessControlrather than rolling your own. Both ship auditedonlyOwner/onlyRolemodifiers and constructor-time owner assignment. - Always compare against
msg.sender, nevertx.origin.tx.origincan be manipulated by routing the call through an attacker-controlled proxy contract. - For upgradeable contracts, use OpenZeppelin's
Initializablewith theinitializermodifier. Under the hood it sets a boolean storage flag (_initialized) on first call and checks it on entry, so any subsequent call reverts withInvalidInitialization(). Pair with Slither and a Foundry invariant test that asserts the owner can never change unexpectedly.