Six contracts that pass EXTCODESIZE(owner) > 0 and still withdraw their order

Iniciado por joomlamz, Hoje at 06:25

Respostas: 1   |   Visualizações: 3

Tópico anterior - Tópico seguinte

0 Membros e 1 Visitante estão a ver este tópico.

Saudações, caros membros e entusiastas da comunidade técnica do **webmastersmz.com**!

Como especialista em tecnologia, analisei o tópico em inglês intitulado **"Six contracts that pass EXTCODESIZE(owner) > 0 and still withdraw their order"** (Seis contratos que passam na validação `EXTCODESIZE(owner) > 0` e ainda assim conseguem retirar a sua ordem).

Esta discussão aborda uma vulnerabilidade e um comportamento fascinante no ecossistema de desenvolvimento Solidity e na EVM (Ethereum Virtual Machine). Abaixo, destaco os pontos principais da análise técnica:

### 1. A falácia do `EXTCODESIZE` como verificação de EOA (Externally Owned Account)
Historicamente, muitos contratos inteligentes utilizam a instrução `EXTCODESIZE(address) > 0` para verificar se um determinado endereço é um contrato ou uma Conta Externamente Controlada (EOA). A lógica assumia que, se o tamanho do código fosse maior que zero, tratava-se obrigatoriamente de um contrato. No entanto, o tópico demonstra como esta premissa pode ser contornada.

### 2. O papel dos Construtores (Constructors)
O ponto nevrálgico discutido no tópico reside no facto de que, durante a fase de execução do construtor de um contrato (`constructor`), o código ainda não foi totalmente implantado no estado global da blockchain, mas o `EXTCODESIZE` já pode retornar um valor maior que zero dependendo do momento da chamada, ou o contrato pode executar lógica maliciosa/inesperada antes mesmo de o endereço ser considerado "totalmente inicializado".

### 3. Bypass de Mecanismos de Segurança
Os seis contratos referidos no tópico exploran subtilezas da EVM para contornar restrições de segurança que confiam cegamente no tamanho do bytecode. Isto serve como um alerta crítico para arquitetos de contratos inteligentes: **nunca utilizem `EXTCODESIZE` como a única forma de prevenção contra chamadas de contratos** (um padrão comum para prevenir reentrância ou interações indesejadas de bots/contratos). A abordagem recomendada hoje em dia passa por verificar explicitamente se `msg.sender == tx.origin` (com as suas próprias limitações) ou adoptar padrões de design mais robustos baseados em papéis e autorizações.

---

O que acham desta análise, caros colegas? Já se depararam com situações semelhantes em auditorias de contratos inteligentes ou nos vossos projetos em Solidity? Como é que têm lidado com a validação de endereços nos vossos smart contracts? **Deixem as vossas opiniões e experiências aqui nos comentários do fórum webmastersmz.com para enriquecermos este debate!**

---

Para garantir que os vossos projetos e fóruns rodam sem falhas, convido-vos a conhecer as soluções de alojamento de alta performance da **AplicHost** em [https://aplichost.com](https://aplichost.com).

Six contracts that pass EXTCODESIZE(owner) > 0 and still withdraw their order



Tópico: Six contracts that pass EXTCODESIZE(owner) > 0 and still withdraw their order
Categoria: Tutoriais | Programação & Tecnologia
Idioma Principal: Português (Conteúdo de Tecnologia)

Descrição do Conteúdo / Informações:
-------------------------------------------------------------------------
On an order book, the depth you see is a promise. The market maker showing you a bid can pull it in the same block you try to take it, and nothing on the screen tells you in advance which levels will hold.

On-chain books make that answerable in a way no off-chain exchange can. A resting order has an Order.owner field, and that field is readable by anyone. So if the owner is a contract with no cancel function, the order can't be withdrawn — and you can prove it before you trade against it.

The obvious way to check is one opcode:

if (owner.code.length > 0) {
// it's a contract, not a wallet — this quote is firm
}

That check is forgeable. I built six contracts that all pass it and all still get their depth out, deployed every one of them to a public testnet, and executed the escapes as real transactions. Here is what breaks, and what to use instead.



Why EXTCODESIZE feels like the right question


You probably know the classic footgun: EXTCODESIZE(addr) == 0 doesn't mean "this is an EOA", because during a constructor the deploying address has no code yet. That one is well documented.

This is a different failure, and I haven't seen it written up. Here the direction is flipped — we're not asking "is this an EOA?" to gate access. We're asking "is this a contract?" to make a positive guarantee to a third party: this depth cannot be withdrawn.

EXTCODESIZE > 0 tells you code exists at an address. It tells you nothing whatsoever about what that code can do. Every attack below lives in that gap.

And a wrong signal here is worse than no signal, because it launders unreliable liquidity through a metric people are trusting.



Six ways to look firm and still get out


Each of these rests a quote exactly the way an honest contract would. Each has a real exit.

1 — Hide the cancel behind a boring name. The pool accepts cancelOrder from the order's owner. The owner is this contract. So it just calls it:

contract HiddenCancel is QuoteBase {
/// @notice "Re-arm after a fill." Actually: cancel the resting order.
function poke() external {
pool.cancelOrder(orders[orders.length - 1]);
}
}

Twelve lines, code.length > 0, reads FIRM to a naive classifier.

2 — Sit behind an upgradeable proxy. The runtime bytecode at the address is an ERC-1967 proxy with no cancel anywhere in it. That's true right up until the implementation is swapped, post-rest, for one that has a cancel. The address never changes. The code at it does.

3 — DELEGATECALL out. Same idea without the proxy ceremony. The contract holds no cancel; it borrows one from an attacker-controlled target and runs it in its own storage context.

4 — Grant an operator after resting. This is the sharpest one, because nothing in this contract's bytecode resembles a cancel:

function openBackDoor(address op) external {
bytes4[] memory sels = new bytes4[](1);
sels[0] = CANCEL_ORDER_FOR;                    // 0xe37b444b
REGISTRY.setOperatorApprovalForPool(address(pool), op, sels, true);
REGISTRY.setOperatorApprovalGlobal(op, sels, true);
}

The contract calls a shared permissions registry and hands a plain wallet the right to call cancelOrderFor(thisContract, id). The pool accepts, because the owner approved it. The withdrawal then happens entirely outside this contract's own selectors. Auditing this contract for cancel paths finds nothing.

5 — Shrink instead of cancel. The subtlest. reduceOrder drops a resting order's remaining quantity in place and refunds the freed escrow — no taker, no fill:

/// @notice "Rebalance the quote." Actually: shrink to a stub, pull collateral back.
function trim(uint256 newRemaining) external {
pool.reduceOrder(orders[orders.length - 1], newRemaining);
}

A 1,000,000-unit quote becomes a minimum-size stub. The order technically survives, which is exactly why this is easy to overlook — but the liquidity it advertised is gone. If your claim is "this depth cannot be withdrawn," a reduce falsifies it directly.

6 — Use the plural. Block the single cancelOrder selector and there's still cancelOrders(uint128[]). Enumerating one selector is not enumerating a capability.



Executed, not theorised


All six are deployed on Somnia Shannon (chain 50312) and each escape was attempted as a real transaction:

#
Contract
On-chain result

S1
HiddenCancel

pulled — poke() cancelled it

S2
Erc1967Proxy

pulled — upgraded impl, then pull()

S3
DelegateEscape

pulled — escape() delegatecalled a cancel

S4
OperatorGranter
grant executed; the pool then blocked the operator cancel

S5
QuietReduce

pulled — trim() shrank 2,000,000 → 1,000,000, no fill

S6
BatchCancel
rested; tidy() gas-blocked on this pool

Four completed a full on-chain withdrawal. S4's back-door grant landed on-chain but the pool's authorizer refused the resulting cancel, and S6's batch path ran out of gas against this particular pool. I'm stating those two honestly rather than rounding them up to six — but note it doesn't rescue the naive check either way. Classification happens while the order is resting, and at rest time all six read FIRM under code.length > 0. Whether a given escape later lands is the attacker's problem, not the classifier's defence.



What to use instead: hash, not size


The fix is to stop asking "is there code here" and start asking "is this exact code something I have examined."

EXTCODEHASH (EIP-1052) is a keccak-256 commitment to the precise runtime bytecode. Attest the hash, not the address. Then:


FIRM — owner's code hash is attested, and still inside its lock window


PULLABLE — owner is a wallet


UNVERIFIED — owner is a contract nobody attested → no claim is made

That third state is the load-bearing one. Every attack above mints UNVERIFIED depth, never FIRM. The metric refuses to speak rather than speaking wrongly.

Before a human attests anything, a static pass over the runtime rejects the obvious escapes — no DELEGATECALL, no SELFDESTRUCT, no CREATE/CREATE2, no EIP-1967 slot constants or EIP-1167 proxy prologue, and no forbidden selector at any byte alignment:

export const FORBIDDEN_SELECTORS = {
'0xdbc91396': 'cancelOrder(uint128)',
'0x0dce6933': 'cancelOrders(uint128[])',
'0x33407b60': 'reduceOrder(uint128,uint256)',
'0x7bbc67e6': 'setOperatorApprovalForPool(address,address,bytes4[],bool)',
'0x7f1e31ce': 'setOperatorApprovalGlobal(address,bytes4[],bool)',
'0x558a7297': 'setOperator(address,bool)',
'0x605e0222': 'approveBuilder(address,uint256)',
};

Two details that cost me time. The scan has to disassemble rather than substring-match, so a selector-shaped run of bytes inside a PUSH32 immediate doesn't produce a false hit. And Solidity's CBOR metadata trailer must be stripped before opcode analysis — otherwise a stray 0xff in it reads as SELFDESTRUCT. But the declared trailer length is attacker-controlled, so an implausible length (mine caps at 128 bytes) must be refused rather than honoured, or a contract can hide live code from the scanner by lying about where its metadata starts.

Against a corpus of the honest contract, the six attackers, and a plain EOA:

Attested classifier:            8/8
Naive EXTCODESIZE classifier:   2/8

The naive check gets only the two trivial ends right — the pure-firm contract and the pure-EOA — and is fooled by all six attacks in between.



The part I can't claim


A green static verdict is not a proof of irrevocability, and I'd rather say so than let the number oversell.

The policy only sees selector bytes that literally appear in the runtime. A contract that computes the selector at execution time — add(0xdbc91395, 1) in Yul, then mstore(shl(224, sel)) and call(...) — invokes cancelOrder(uint128) while the bytes dbc91396 appear nowhere in its code. It passes every clause above and is fully withdrawable. That isn't hypothetical; I wrote it as StealthCancel and it beats my own analyzer.

More generally: CALL can't be banned (the honest contract needs it to place the order), and linear disassembly can't follow jumps, so it can't always tell code from jump-reachable data. No static scan over a language permitting arbitrary CALL can be made sound.

So attestation is a human-reviewed transparency list, and the analyzer is the cheap pre-filter that runs before a human looks — a precondition for review, never a replacement. The honest version of the claim is narrow: this specific bytecode, which I have read, has no path to withdraw.



The transferable bit


If you are using EXTCODESIZE/code.length anywhere to make a promise about behaviour rather than to check identity, it will not hold. Code size is not a capability. Address identity is not code identity, because proxies exist and implementations move.

Hash the runtime, pin the hash, and make "I haven't checked this one" a first-class answer your system is willing to give.

Full evidence trail with every transaction hash, the reproduction commands, and the honest-limits section: github.com/edycutjong/rampart · live typed-book viewer at rampart.edycu.dev/viewer.

Everything above reproduces offline — forge test (93 passing) and node script/headline.mjs (8/8 vs 2/8) need no wallet, no gas, and no network. If you find a seventh escape, I'd genuinely like to know.


Joomlamz
Consultoria em Informática
-------------------------------------------------------
Especialista em Sistemas Web & Manutenção de Servidores.
A desenvolver o novo AplPortal com suporte a PHP 8.
Precisa de ajuda profissional? Contacte-me.

Tags: