Alp Bolukbasi
Karlsruhe, DE

M.Sc. Computer Science @ KIT : hardware-aware systems engineer

somewhere between the debugger and the datasheet


01 // Note


aes [source ↗]

Fault Injection and DFA on an FPGA AES Core

I built a differential fault analysis (DFA) collection setup against an AES-128 core on a Lattice iCE40HX8K, in the exact shape of a Piret-Quisquater key recovery needs with eight correct and faulty pairs. The setup uses 4800 on-chip ring oscillators on the same die as the victim to inject timing faults through voltage sags that supply hard enough to break AES critical path. I did not run the key recovery itself and instead the deliverable is the fault set and which faults are usable.

Hardware setup and threat model

A fault attack pushes the circuit outside its safe operating conditions so that one computation goes wrong in a controlled way however nothing here breaks AES at the algorithmic level. The attacker then reads the secret back out of the wrong result. On this board, the disturbance is generated on-chip by the victim's own neighbour in the FPGA fabric. This matches the threat model behind shared and cloud FPGAs, where two tenants are isolated in logic but share one power distribution network. In this case, the ring oscillators are targeted and a usual disturbance is usually a glitch on the clock or supply.

TargetAES-128, fault at the round-9 input
PlatformLattice iCE40HX8K
Fault source4800 on-chip ring oscillators
Deliverablecorrect and faulty ciphertext pairs for DFA

Ring oscillators as a fault source

A ring oscillator is a single inverting gate wired back to its own input and never settles. It flips as fast as the silicon allows and draws switching current on every transition. The design places 4800 of them on the die and a 8-bit mask gates the array, with each mask bit enabling groups of 600 oscillators.

Switching the grid on pulls a sudden current from the shared supply (oscillators), which sags the rail for a moment. Gate delay grows as the supply voltage drops, exactly called an IR drop for details. This sag stretches the AES critical path. When the path no longer settles inside the 60 MHz clock period, a flip flop latches an unfinished value, entering a wrong byte into the AES state. The toggle counter free-runs, making the sag land at a different point in every encryption. This creates a timing fault and only aimed a single byte instead of a round. The idea is that, I ran many encryptions and filter the outputs for the required fault shape. In the provided top_level.v from lab, the randomness comes from one detail, which is the toggle counter cycles_ctr. Toggle counter cycles_ctr does never reset between each encryption, and only freezes while injection is off and resumes from wherever it stopped. Therefore sag lands at a different point in every encryption. A hardware attack like this is statistical since you need to run many encryptions and filter the outputs for the fault shape I want.

The useful fault shape

AES-128 runs ten rounds and the tenth round omits the MixColumns operation. Take a single wrong byte at the input of round 9. SubBytes and ShiftRows keep it a single byte. MixColumns then spreads that byte across all four bytes of its column however because round 10 has no MixColumns, those four bytes are only permuted with ShiftRows into four fixed ciphertext positions. A usable fault shows up as exactly four altered ciphertext bytes on one diagonal where there are four diagonals, one for each round 9th column.

The four DFA fault groups, each a diagonal of the ciphertext state matrix
The four fault groups. A single-byte fault at the round-9 input diffuses through MixColumns into one output diagonal.

I did not hardcode any byte positions and computed the four groups from the ShiftRows permutation in the AES' own shiftrows.v. They stay correct if the byte ordering ever changes:

# ShiftRows forward map (input byte index -> output byte index), from shiftrows.v
_SR = {0: 0, 4: 4, 8: 8, 12: 12,
       5: 1, 9: 5, 13: 9, 1: 13,
       10: 2, 14: 6, 2: 10, 6: 14,
       15: 3, 3: 7, 7: 11, 11: 15}

GROUPS = [tuple(sorted(_SR[4 * c + r] for r in range(4))) for c in range(4)]
# -> [(0,7,10,13), (1,4,11,14), (2,5,8,15), (3,6,9,12)]
_POS_TO_GROUP = {pos: gi for gi, g in enumerate(GROUPS) for pos in g}

For one group I guessed its four last-round key bytes. I undid the last round on both the correct and the faulty ciphertext, then tested whether the difference could have come from a single-byte MixColumns input. Wrong guesses failed the test and One fault narrowed those four key bytes to a small candidate set and a second fault in the same group made them unique. Four groups with two faults each, plus one correct ciphertext, yields to the full last-round key. The collection target was two usable faults per group, and eight in total.

The classifier and its baseline

The host software includes a classifier that sorts each result because not every wrong ciphertext is a usable fault, and a classifier compares the faulty ciphertext against the fault-free one, reads which bytes changed, and decides which diagonal they fall on. It labels the outcome clean when nothing changed and it labels the outcome usable when exactly four bytes on one diagonal changed. A partial label means one to three bytes on one diagonal changed, indicating a round 10 fault. A spread label means the changes crossed more than one diagonal, indicating the fault happened before round 9. I discarded partial and spread faults as my goal was to create a "correct fault".

def classify_fault(correct, faulty):
    """
    "clean"    -- identical ciphertext, no fault
    "usable"   -- exactly 4 bytes differ, all inside one diagonal group
                  (a single-byte round-9 fault; this is what I needed for a DFA)
    "partial"  -- 1..3 bytes differ, all inside one group (a late round 10 fault; the weaker 1-byte-key model, not the standard one)
    "spread"   -- differing bytes span more than one group (fault earlier than round 9, or multiple faults; not usable)
    """
    diff = diff_positions(correct, faulty)
    if not diff:
        return "clean", None

    groups_hit = {_POS_TO_GROUP[i] for i in diff}
    if len(groups_hit) != 1:
        return "spread", None

    group = groups_hit.pop()
    return ("usable" if len(diff) == 4 else "partial"), group

I validated the classifier against a software AES before trusting it on hardware. I injected single-byte faults at the round 9 input in software. The classifier flagged every one as usable with the correct group, with zero misclassifications over 3200 trials. Then I injected round 8 faults. It rejected all of them as spread, with zero false positives. Only then the faulty ciphertexts from the board made sense.

calibration

The injector has three settings: active oscillator count for how many oscillators to enable, toggle period, and active duty cycle which's the fraction of that period for grid to be stay active. The correct configuration depends on the physical board. The calibration tool sweeps the settings, fires a batch of encryptions with random plaintexts for each configuration, and counts the classifier's verdicts.

The useful region is a thin ridge. Too little energy produces no faults. Too much energy faults almost every encryption, but the faults spread across several diagonals and are useless. The single-byte faults occur in a narrow band where 10 to 40 percent of encryptions fail.

Fault outcome composition against injection intensity, showing usable faults only in a narrow band
Outcome composition as injection intensity rises. Usable single-byte faults appear only in the shaded 10 to 40 percent band. Beyond it, spreads take over.

Reducing the number of active oscillators below 4800 did not lower the fault rate gradually. On this board, it dropped to zero. The working point I settled on used the full grid, a short toggle period, and a 60 percent duty cycle.

Board-specific failures

The FIPS-197 known-answer test returned a repeating 32-bit garbage word instead of the expected ciphertext. The provided bitstream closed timing at 59.25 MHz, under the 60 MHz target, and my board's silicon was slightly slower. Rebuilding from the same source with the correct toolchain closed timing at 62 MHz. The known-answer test then passed:

Plaintext:  3243f6a8885a308d313198a2e0370734
Ciphertext: 3925841d02dc09fbdc118597196a0b32

I applied a strict rule for the remaining work. The known-answer test must pass first. If it does not return the FIPS vector, no collected data is trustworthy.

Newer Yosys versions synthesized each oscillator as two lookup tables instead of one. Placing 4800 oscillators at two LUTs each overflowed the device, failing place-and-route at 154 percent utilization. The cause was the keep attribute on the feedback wire pinning an intermediate net. I rewrote the oscillator as a single NAND gate so the kept net was the loop node. This folded the oscillator back to one LUT and fit the design at 91 percent utilization:

module ringosc( enable, out );
        input enable;
        output out;
        (* keep *) wire A /* synthesis syn_keep=1 keep=1 */;
        // original: two LUTs per RO on yosys >= 0.6, 154% utilization, P&R fails
        //   assign A = ~A && enable;
        // fixed: one SB_LUT4 (NAND, INIT=0x0FFF) per RO on both toolchains.
        // Only the disabled steady level differs (1 instead of 0), which is
        // irrelevant to the fault attack.
        assign A = ~(A && enable);
        assign out = A;
    endmodule

Fault collection set

The collection script encrypted random plaintexts, kept the usable faults, and stopped once every diagonal had two. One run filled all four groups with eight usable faults in 1100 attempts, taking about one minute. Each pair recorded the plaintext, the fault-free ciphertext, the faulty ciphertext, and the altered byte positions.

I verified every collected pair against a software AES. The fault-free ciphertexts matched exactly. Each faulty ciphertext differed in precisely the four positions of its group. The set is internally consistent and sufficient for a Piret-Quisquater key recovery.

With a working bitstream and a calibrated operating point, collection is simple. Encrypt random plaintexts at mask 0xff, cycles 8, activecycles 5, keep only the usable faults, and stop once every diagonal has two. One run filled all four groups in roughly 550 to 1100 attempts, about a minute of wall-clock time. Each kept pair records the plaintext, the fault-free ciphertext, the faulty ciphertext, and the altered byte positions.

Finding the board took longer than it should have. The iCE40-HX8K breakout exposes an FTDI bridge, and on macOS /dev/cu.Bluetooth-Incoming-Port also matches a /dev/cu.* prefix and gets picked first, so the port lookup matches on the USB vendor id instead of the device name. RTS is wired to rstin, which is active low, so setRTS(True) asserts reset. Swapping those two lines holds the FPGA in reset and every read times out with no error message:

def reset(self):
    # RTS is wired to rstin (active low): setRTS(True) drives the pin low =
    # reset asserted. Do not swap these or the FPGA stays held in reset.
    self.ser.setRTS(True)
    time.sleep(0.001)
    self.ser.setRTS(False)
    time.sleep(0.001)
    self.ser.reset_input_buffer()
    self.ser.reset_output_buffer()

As a last check I re-verified every collected pair against a software AES. The fault-free ciphertexts matched exactly, and each faulty ciphertext differed in precisely the four positions of its group. The set is internally consistent andsufficient for a Piret-Quisquater key recovery.

Limits

The yield of usable faults is low and I do not have one clean number for it. The collection run gives about 0.7 percent, eight faults in roughly 1100 attempts. The committed calibration CSV gives 12 percent usable at the same operating point and the sweep used for the figure gives 2 percent, with a fault rate of 28 versus 19 percent. Those are separate runs on separate days and I did not repeat them enough to say which is representative. The honest statement is that the yield is somewhere in the low single digits and varies more between sessions than I expected.

The calibration parameters are specific to this board and would need remeasuring on another. I also did not run the key recovery. Reporting a validated fault set is more honest than presenting a half-finished recovery, but it does mean the last claim in this note, that the set is sufficient, rests on the structure of the fault pattern rather than on a recovered key.

References and implementation

  • J. Krautter, D. R. Gnad, M. B. Tahoori, FPGAhammer: Remote Voltage Fault Attacks on Shared FPGAs, suitable for DFA on AES, TCHES 2018.
  • G. Piret, JJ. Quisquater, A Differential Fault Attack Technique against SPN Structures, with Application to the AES and Khazad, CHES 2003.
  • E. Biham, A. Shamir, Differential Fault Analysis of Secret Key Cryptosystems, CRYPTO 1997.
  • NIST, FIPS PUB 197: Advanced Encryption Standard (AES).
  • FPGA control software, fault classifier, and calibration tooling