Access_Control picoCTF 2026 Solution

Published: March 20, 2026

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

Download and read AccessControl.sol.

Set up Foundry or cast/web3 to interact with the deployed contract.

bash
cat AccessControl.sol

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Read AccessControl.sol and find the exposed function
    Observation
    I noticed the challenge explicitly provides the contract source (AccessControl.sol) and states only the owner can access the flag, which suggested the vulnerability was a missing or broken access check on a public function that writes to the owner variable.
    Open the source and look for any externally callable function that writes to owner. In this contract there's a public changeOwner(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 separate solve() call that requires msg.sender == owner, and getFlag() reverts until solve() has flipped the revealed flag. See smart contract CTF bugs for the full taxonomy of access control flaws.
    bash
    cat AccessControl.sol
    What 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() calls require(revealed, 'Challenge not yet solved!') and reverts immediately. The flag is not sitting in unguarded storage - it is locked behind the revealed boolean that only solve() can flip, and solve() itself requires msg.sender == owner. Skipping the ownership chain means every read call reverts.

    Tried: Search for a tx.origin bypass by deploying an intermediary contract and routing the changeOwner call through it.

    This contract checks nothing at all in changeOwner - there is no msg.sender guard to bypass and no tx.origin check to trick. A plain direct cast send with your own address as the argument is all that is needed. Deploying an intermediary contract adds unnecessary complexity and consumes extra gas without any benefit.

    Learn more

    Access control in Solidity smart contracts relies on the developer correctly checking who is calling a function. The canonical pattern is an onlyOwner modifier that compares msg.sender to a stored owner address. When this check is missing, broken, or bypassable, any caller can claim ownership.

    The four flaws to scan for in a CTF contract:

    1. The owner variable is public and set in a function anyone can call (this challenge).
    2. tx.origin is used instead of msg.sender. tx.origin is always the EOA that signed the outermost transaction, so a victim calling an attacker contract still authenticates as the victim.
    3. The initialize() function lacks a flag preventing re-initialization, letting anyone replay it after deployment.
    4. A pre-0.5 contract uses a same-named function as a constructor (typo or deliberate), making it permanently callable.

    The Foundry toolkit's cast command-line tool is invaluable for interacting with deployed contracts without writing a full script. cast call reads state, cast send sends transactions. Read the Solidity source first, always.

  2. Step 2
    Become the owner
    Observation
    I noticed that changeOwner(address) is a public function with no msg.sender guard, which suggested that calling it with my own address as the argument would unconditionally transfer ownership to me.
    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.
    bash
    YOUR_ADDR=$(cast wallet address --private-key $PRIVATE_KEY)
    bash
    cast send $CONTRACT 'changeOwner(address)' $YOUR_ADDR --rpc-url $RPC_URL --private-key $PRIVATE_KEY --legacy --gas-limit 200000

    Expected output

    picoCTF{4cc3ss_c0ntr0l_byp4ss_...}
    What didn't work first

    Tried: Omit --legacy and let cast use EIP-1559 fee fields (maxFeePerGas / maxPriorityFeePerGas) on the challenge chain.

    The picoCTF challenge chain does not support EIP-1559 and returns an error like 'transaction type not supported' or silently drops the transaction. Adding --legacy forces cast to send a legacy Type-0 transaction that the chain accepts. Without it the cast send appears to complete locally but the transaction is never mined.

    Tried: Call cast send without providing $YOUR_ADDR as the argument, relying on msg.sender being picked up automatically.

    changeOwner(address _newOwner) takes an explicit address parameter and stores exactly what you pass - it does not read msg.sender. Calling it with no argument causes cast to fail with an ABI encoding error. You must pass your own address as the argument so the contract writes it to the owner slot.

    Learn more

    In Ethereum, all state-changing contract calls are transactions that must be signed by a private key. Here changeOwner(address _newOwner) writes owner = _newOwner with no guard, so you pass your own address as the argument and the contract records you as owner. (Contrast with the owner = msg.sender pattern, 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 KEY to derive your address, then sign transactions with --private-key. Always confirm state changes with cast call after each cast send.

  3. Step 3
    Call solve() to reveal the flag
    Observation
    I noticed that getFlag() reverts until a separate revealed boolean is set to true, and only the owner-gated solve() function flips that boolean, which suggested I had to call solve() as the new owner before getFlag() would return anything.
    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().
    bash
    cast send $CONTRACT 'solve()' --rpc-url $RPC_URL --private-key $PRIVATE_KEY --legacy --gas-limit 200000
    What 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() contains a separate require(revealed, 'Challenge not yet solved!') guard that checks a boolean storage variable, not ownership. The revealed boolean is only set to true inside solve(). Ownership lets you call solve(), but you still must call solve() first before getFlag() will return anything other than a revert.

    Tried: Read the flag from the FlagRevealed event emitted by solve() using cast tx instead of calling getFlag() afterward.

    cast tx shows transaction metadata but does not decode event logs by default. You would need cast receipt with --json and then parse the logs field manually, which is more work than a simple cast call to getFlag(). The event approach is valid but requires extra steps to decode the ABI-encoded string from the raw topic/data bytes.

    Learn more

    This contract splits the win condition into two functions: an unguarded changeOwner that anyone can call, and an owner-gated solve() that performs the privileged action. The vulnerability is entirely in the missing access check on changeOwner; solve() itself is correctly guarded with require(msg.sender == owner). You satisfy that guard only because the previous step made you the owner.

    The flag is delivered through the FlagRevealed event that solve() emits, and is also retrievable afterward via getFlag(). Reading transaction logs (events) is a common way CTF contracts hand back data, so inspecting the solve() transaction receipt is an alternative to calling getFlag().

  4. Step 4
    Verify ownership transferred
    Observation
    I noticed that solve() reverts with 'Only the owner can get the flag' if ownership was not properly set, which suggested confirming the owner slot matched my address with a free cast call before attempting solve().
    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.'
    bash
    cast call $CONTRACT 'owner()(address)' --rpc-url $RPC_URL
    bash
    # Output should match $YOUR_ADDR
    Learn more

    This is the cheapest sanity check available. cast call queries 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, your changeOwner() reverted silently or you sent it from the wrong key.

  5. Step 5
    Read the flag
    Observation
    I noticed that once solve() sets revealed to true, the getFlag() function is the only public interface that returns the flag string, which suggested issuing a read-only cast call with the correct ABI signature to retrieve and decode 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!'.)
    bash
    cast call $CONTRACT 'getFlag()(string)' --rpc-url $RPC_URL --from $YOUR_ADDR
    Learn more

    cast call executes 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_getStorageAt RPC 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.

Flag

Reveal flag

picoCTF{4cc3ss_c0ntr0l_byp4ss_...}

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

Smart contract access control fails when any public function can mutate the owner variable without checking msg.sender; a missing onlyOwner guard turns a claimed owner-only operation into one that any account can invoke with a single transaction. The fix is to use OpenZeppelin's audited Ownable or AccessControl, which enforce the caller check in a tested modifier rather than leaving it to each function to implement correctly.

How to prevent this

Smart contract access control is mostly about not trusting tx.origin and not leaving init functions reachable.

  • Use OpenZeppelin's Ownable or AccessControl rather than rolling your own. Both ship audited onlyOwner / onlyRole modifiers and constructor-time owner assignment.
  • Always compare against msg.sender, never tx.origin. tx.origin can be manipulated by routing the call through an attacker-controlled proxy contract.
  • For upgradeable contracts, use OpenZeppelin's Initializable with the initializer modifier. Under the hood it sets a boolean storage flag (_initialized) on first call and checks it on entry, so any subsequent call reverts with InvalidInitialization(). Pair with Slither and a Foundry invariant test that asserts the owner can never change unexpectedly.

Related reading

Want more picoCTF 2026 writeups?

Useful tools for Blockchain

What to try next