Polygon zkEVM: Missing constraint in PIL leading to execution flow hijack

From WEB3 Vulnerapedia
Jump to navigation Jump to search

Polygon zkEVM: Missing constraint in PIL leading to execution flow hijack

Identified By: Hexens

Background

The combination of the free input checking in zkEVM ROM and a missing constraint in main.pil leads to execution hijack with a possibility to jump to an arbitrary address in ROM.

The Vulnerability

One of the impacts is the arbitrary increase of balance for any caller.

In the file utils.zkasm some of the procedures use free input calls to make small calculations, for example,

computeSendGasCall:

 <; C = [c7, c6, ..., c0]
 ; JMPN instruction assures c0 is within the range [0, 2^32 - 1]
 ${GAS >> 6} => C        :JMPN(failAssert)
 ${GAS & 0x3f} => D
 
 ; since D is assured to be less than 0x40
 ; it is enforced that [c7, c6, ..., c1] are 0 since there is no value multiplied by 64
 ; that equals the field
 ; Since e0 is assured to be less than 32 bits, c0 * 64 + d0 could not overflow the field
 C * 64 + D              :ASSERT</code>

In such cases, to ensure the validness of the free input JMPN is used. JMPN will effectively check whether the free input set in register C is in the range [0,2^32-1]. This is a security assumption that ensures that the register is not overflowing in the assertion stage:

C * 64 + D              :ASSERT

The JMPN constraints: https://github.com/0xPolygonHermez/zkevm-proverjs/blob/develop/pil/main.pil#L209-L228

pol jmpnCondValue = JMPN*(isNeg*2^32 + op0);

By checking that the jmpnCondValue is a 32-bit number we assure that the op0 is in the [-2^32,2^32) range, thus preventing the overflow. The jump destination, as well as the zkPC constraints, are consequently based on the inNeg: https://github.com/0xPolygonHermez/zkevm-proverjs/blob/develop/pil/main.pil#L322-L336

zkPC' = doJMP * (finalJmpAddr - nextNoJmpZkPC) + elseJMP * (finalElseAddr - nextNoJmpZkPC) + nextNoJmpZkPC;

Nonetheless, a constraint is missing to ensure that isNeg evaluates only to 1 or 0. In the case of utils.zkasm procedures, there is no elseAddr specified, and having:

<finalElseAddr = nextNoJmpZkPC
doJMP = isNeg
elseJMP = (1-isNeg)

The zkPC constraint can be reduced to:

zkPC' = isNeg * (finalJmpAddr - nextNoJmpZkPC) + nextNoJmpZkPC

Where both finalJmpAddr and nextNoJmpZkPC are known values in the ROM program compilation phase.

In order to be able to jump to arbitrary zkPC, the attacker needs to calculate corresponding values for isNeg and op0; this can be done using derived formulas:

<isNeg = (zkPC_arbitrary - nextNoJmpZkPC) * (finalJmpAddr - nextNoJmpZkPC)-1 mod P
op0 = - isNeg * 232 mod P

The attack breakdown:

At this point attacker has a primitive to jump to an arbitrary address; the next step will be to find a suitable gadget to jump to, the main requirements for the target are to:

  • Not to corrupt/revert zkEVM execution
  • Impact favourably for the attacker

One of the jump chains found is to use one of the *CALL opcodes as the start of the attack chain to call the computeSendGasCall and subsequently craft a jump into the refundGas label's code: https://github.com/0xPolygonHermez/zkevm-rom/blob/develop/main/process-tx.zkasm#L496-L498

 <$ => A                          :MLOAD(txSrcOriginAddr)
 0 => B,C                        ; balance key smt
 $ => SR                         :SSTORE

This will set txSrcOriginAddr balance to the value contained in register D and finish the transaction execution. To abuse the value set by the SSTORE instruction, attacker needs to set huge value in the register D, for this the DELEGATECALL opcode can be used, as in the implementation it sets the register D just before the computeSendGasCall call:

$ => D          :MLOAD(storageAddr)

...

E               :MSTORE(retCallLength), CALL(computeGasSendCall); in: [gasCall: gas sent to call] out: [A: min( requested_gas , all_but_one_64th(63/64))]

So the D register will be set with storageAddr which has very big absolute value.

Additional setup for the attack:

  • A contract should be deployed with a function (or fallback) that initiates a delegatecall() call to any address.
  • The transaction should be initiated with gasPrice set to 0 not to overflow in gas when sending it to the sequencer, as well as this will favor the fact of attacker getting to prove the batch initiated
  • The gasLimit should be precalculated to end up with 0 gas at the end of the execution, this is done for the same reason mentioned above.

The Fix

To fix this issue a constraint must be added for the inNeg polynomial to ensure that it evaluates only to 0 or 1. e.g.

 isNeg * (1-isNeg) = 0

References

Hexens Audit Report

Fix Commit

https://github.com/0xPARC/zk-bug-tracker#hexens-polygonzkevm-3

Related Vulnerabilities

Under-Constrained Circuits vulnerability