Bits and Bytes Security

Reproducing the RP2350 OTP Power Glitch Attack

Introduction

In this blog post, I walk through my experience replicating the RP2350 OTP hack by Aedan Cullen. I followed the progress of the RP2350 Hacking Challenge (the first edition; Raspberry Pi later launched a second edition), and when the deadline arrived there were multiple solutions. Some were more interesting than others, in my opinion. One I found particularly interesting was Aedan Cullen’s, which relies on glitching the OTP power source. I was keen to try it myself, but I faced some challenges along the way. This post describes my experience attempting to reproduce the attack, including the pitfalls.

RP2350 and the necessary trace cut.
RP2350 and the necessary trace cut.

Since the hardware is expensive, it made sense to use our existing lab hardware as much as possible. That led me to perform the attack using different hardware than Aedan used. Both lab setups can be compared in the picture above.

Attack Description

To read the OTP secrets, Aedan’s attack caused the RP2350 to boot the RISC-V cores instead of the ARM. Because the OTP protections and chip security were implemented for the ARM, using the RISC-V makes it possible to read the OTP secrets without those restrictions.

To boot the RISC-V instead of the ARM, Aedan glitched the OTP power pin during a CRITICAL register read, which corrupted the value read to 0x0003033. A value of 0x0003033 means both the RISCV_DISABLE and ARM_DISABLE bits of the CRITICAL register are set, and, coincidentally, when these two bits are set the RP2350 boots the RISC-V. Crazy luck…

Experimental Setup

Experimental setup lab.
Experimental setup lab.

  • The Target is the RP2350 of Pico 2 board with secrets inside OTP fuses. To glitch the OTP_VDD of the target, we need a way to isolate OTP_VDD from the core VDD.
  • The miniCurrent is used to measure OTP_VDD current and locate when CRITICAL register is read.
  • The J-Link is the JTAG adapter board.
  • The Switch is the glitch circuit, that shorts OTP_VDD to ground.
  • The Picoscope 2406B is an analog USB oscilloscope.
  • The Pico 2 Aux is an extra Pico 2 board to control the target state (on/off).
  • The Laptop has the pico-otp-glitcher code which prepares the different elements and performs the attack automatically.

Table of Contents

Given the long post, I’ve split it into different sections.

  1. Attack Description
  2. Preparing the Target
  3. OTP Read Sequence
  4. Target Control
  5. Glitching Signal
  6. Glitching Circuit
  7. Automation & Orchestrator
  8. Debugging & Failed Attempts
  9. The Real Problem: Jitter
  10. Conclusion

Preparing the Target

In order to isolate the RP2350 OTP_VDD connection in the Pico 2 board we have to desolder the RP2350, make a cut in the power trace, and resolder it.

The element that is covered in this section.
The element that is covered in this section.

Using a hot-air gun is the most practical approach to desolder the RP2350 chip (QFN package). This step shouldn’t be hard at first glance (and from watching some YouTube videos it may look easy). But if you look closely you’ll see several tiny SMD resistors and capacitors around the chip.

RP2350 chip of the challenge, and surrounding parts.
RP2350 chip of the challenge, and surrounding parts.

Heating the RP2350 will also easily melt surrounding components’ solder, and a wrong tweezer move can mistakenly disconnect one of these components. I looked into more professional ways of desoldering these types of chips. Apparently, pre-heating the board from the bottom to a certain temperature helps.

Attempt to desolder with pre-heating bed and K-Type Thermometer.
Attempt to desolder with pre-heating bed and K-Type Thermometer.

To my disappointment this didn’t help much… I probably did something wrong. I resorted to increasing the hot-air gun temperature and desoldering in the simplest way.

One thing that helped was using a reasonable hot-air gun fan speed. A temperature of 250–300ºC and a fan speed of 6–7 worked well on this particular hot-air gun. It’s hard to describe in text, but it felt that a slow fan would not spread the heat enough across the chip and PCB to melt all pads’ solder.

PCB trace cut idea.
PCB trace cut idea.

This was new to me. How do you cut a 200 µm trace as illustrated in the picture above? Aedan said he used a knife. I tried twice, and on the second attempt the knife cuts completely removed a PCB pad from the board…

Cutting a PCB trace with a knife, doesn’t look nice…
Cutting a PCB trace with a knife, doesn’t look nice…

Anyway, will this still work? I don’t need to solder the OTP_VDD pad that is gone, as long as I can connect to the RP2350 OTP_VDD pin… first we have to resolder the RP2350.

For the resoldering I used the hot-air gun and it was easier. However, after turning it on it didn’t work… 🤨 Can you spot the problem in the picture below? 🤔 You have to zoom in a little.

Mmmh.. Pico2 not working, wonder why?
Mmmh.. Pico2 not working, wonder why?

I inspected the chip-pad soldering and found that the chip was very misaligned; on one side the pins were disconnected or even shorting the wrong pads. Very messy 🤦‍♂️

OK, but it can be fixed (if the chip still works!). Applying the hot-air gun reflows the solder, which draws the component into precise alignment due to surface tension. Here’s a video showing that effect.

RP2350 resolder self-aligning with hot-air gun.
RP2350 resolder self-aligning with hot-air gun.

Once it was soldered, I had to confirm it was working. To help connect to the OTP_VDD pin I used a PCbite probe and a digital microscope to see it better.

RP2350 resolder self-aligning with hot-air gun.
RP2350 resolder self-aligning with hot-air gun.

After connecting to the PC and opening the serial port, it showed the “hello world” message. Great, it was working… but not for long. Due to mishandling or the soldering/desoldering process, the chip was damaged and completely stopped working after a while. 😒

Isolating the OTP_VDD got me stuck for a while. I wasn’t sure how to isolate this pin in a way that wouldn’t damage the chip. The process of reworking small-footprint components requires a lot of practice and trial and error. One may still end up with a broken chip unless, in my opinion, specialized hardware is used.

I spent a few weeks away from this project. I thought about fabricating a custom Pico2 PCB, but it was a bit pricey (~80€ from JLCPCB). Then I came across the Hardware Design with RP2350 document from Raspberry Pi, which provides a “minimal board” design shown below.

RP2350 reference design.
RP2350 reference design.

Since it provided KiCad design files, I was able to quickly map the components to JLCPCB basic parts and get an estimate: ~40€ for 2 assembled boards. Not cheap, given that a Pico 2 board costs ~8€. However, I felt stuck and this was a way to resolve the bottleneck, so I decided to go for it.

I only had to make small adjustments to the board:

  1. Add a pin header for the OTP_VDD
  2. Change the SMD switches to PDIP ones (cheaper!)
  3. Remove pin-headers from the fabrication BOM
  4. Replace the SWD connector to pin-header
  5. Ensure RP2350A revision A2 is used (newer is patched against this attack)

This made the board cheaper and compatible with what we needed for the attack. The final version is shown below.

RP2350 customized reference design board.
RP2350 customized reference design board.

Note: You can find the KiCad project files in the GitHub repo of this post at the end.

I tested that the fabricated board was working as expected. Now that the OTP_VDD is isolated, we have to capture the OTP current and find the pattern of OTP reads from the chip bootstrap.

OTP Read Sequence

The element that is covered in this section.
The element that is covered in this section.

In Aedan’s attack, a uCurrent was used to measure the current drawn by the OTP_VDD pin. This is a neat current-to-voltage converter based on a shunt resistor that should be tailored to the scale of currents we’re measuring.

I tried to find this gadget locally but it was either expensive (~$60) or out of stock. So I decided to port the existing project to KiCad and order a PCB + assembly from JLCPCB. As I did this, I learned which parts could be adjusted to squeeze a few euros out: a special inverted LED, an uncommon DP3T slide switch, and a golden-plated “bind post” (the “banana-type” female connector).

I wrote a blog post about this uCurrent variant: miniCurrent Project. Feel free to check it for more details. The final result is the following:

Figure X: miniCurrent with enclosure.
Figure X: miniCurrent with enclosure.

While trying to use the miniCurrent, I made some careless mistakes. This instrument is meant to be connected to a multimeter. You can connect it to an oscilloscope, but you need to consider that the output of the miniCurrent is differential. It has a “virtual” ground to allow negative measurements of current (for example, -120 mV → -120 μA on the 10 Ω scale).

The first mistake was tying the same ground between the miniCurrent and the USB connections. This created a ground loop through the miniCurrent, causing problems. The second mistake was measuring a current above the maximum shunt resistor rating. While nothing smoked, it didn’t smell good 😅

To avoid having to order a new board due to mistakes, I made some tweaks to the original uCurrent design. I’m now using an external 10 kΩ resistor. In addition, I added support for more BNC connectors (an official Wurth Electronics one and a cheap brandless one from AliExpress) and an external battery connector (3×AAA). For more information, see the miniCurrent Project page.

OK, now that we have the target and the current-measuring device, let’s test some captures of the OTP_VDD current draw. What are we looking for? What does the current pattern look like when the RP2350 is booting and reading critical registers from the OTP?

Aedan’s presentation slide 35 (with annotations).
Aedan’s presentation slide 35 (with annotations).

The first spike marks when the OTP reads start. After that, it is said 16 guard reads are done prior to reading the critical register to protect against power faults.

But when I did my first current captures, I got a plot like the one shown below.

Initial current captures looked like this.
Initial current captures looked like this.

The current was steady at around 30 μA (from the miniCurrent mV/μA scale), not changing over time. What is happening—where are the OTP reads? This got me stuck for a while again. Is the miniCurrent working as expected? I tested it outside this circuit and it seemed to be working. Am I connecting it incorrectly? I started forming multiple hypotheses about what could be wrong.

I was capturing around the same window as Aedan (~600 μs). Then I did something very basic: I extended the capture time a bit. Still nothing—everything was static, no signs of OTP consumption! 🤔

The Missing 1.4ms

By chance, one day I extended even more—to 1 ms, then 2 ms of capture—and bingo, there it was!

Found RP2350 OTP reads!
Found RP2350 OTP reads!

The explanation for the delay is that Aedan’s captures are shown from his trigger point, so they do not show the time from when the target is reset. I had overlooked Aedan’s Hacking the RP2350 repository, where he says:

(…) Just drop USB_OTP_VDD for 50 μs or so across the CRIT0 and CRIT1 OTP PSM reads, which on my chips are around 220-250 μs from the characteristic current spike that marks the beginning of the OTP PSM sequence. (I trigger my scope on that spike.)

My trigger was being set from the reset signal instead. So either I had to delay the capture by ~1.4 ms or use a different trigger configuration.

So far this is what was done:

  • The RP2350 target was prepared to allow capturing the OTP_VDD current (and also to glitch it when the glitching circuit was ready);
  • The current-measuring circuit is working;
  • The OTP reads were located (~1.4 ms from reset);

Next I will explain how I decided to control the target power.

Target Control

The attack requires glitching the OTP_VDD right after the RP2350 is powered, during boot. Glitching is not deterministic, so we need to try multiple times. Hence, we need a way to programmatically control the target: start it and turn it off.

The element that is covered in this section.
The element that is covered in this section.

A simple way to control the target is through the RUN pin which can be seen as the reset signal. In case of RP2350, we pull it down to ground to turn off the chip. If it’s unconnected, it will be high due to the internal pull-up, thus in running mode.

My PicoScope doesn’t have digital outputs, but it does have an AWG which can be used for the glitching signal (more about this below). So I used another Pico 2 board to control the target. I wrote basic code that provides a prompt and, using short commands, turns the target on for a period of time. By default, the target-control pin output is low to keep the target turned off.

Since the RUN pin is pulled up internally, our target-control signal must have two states: high impedance (RP2350 active) or low (to turn the RP2350 off). Otherwise we create a VDD loop. It probably isn’t harmful given the low voltage/current in the loop, but if we can avoid it, why not.

void set_target_off(void) {
  gpio_put(reset_gpio, 0); // set output LOW
  gpio_set_dir(reset_gpio, GPIO_OUT); // set direction out
}

void set_target_on(void) {
	// Set pin in high-impedance (input direction)
	gpio_set_dir(reset_gpio, GPIO_IN);
}

The other relevant code runs when the target-control Pico 2 receives a command from the host to switch the target on. The command is o [<us_delay>] where the delay is the time the target will continue running after the reset. By default, it’s 5 seconds.

void handle_on_command(char *buf)
{
  unsigned int timeout = DEFAULT_TIMEOUT_US;
  if (sscanf(buf + 2, "%u", &timeout) == 1) {
    printf("[glitcher] Timeout set to %u microseconds.\n", timeout);
  } else {
    printf("[glitcher] Using default timeout of %u microseconds.\n", 
    DEFAULT_TIMEOUT_US);
  }
  
  set_target_on();

  // Cancel any existing alarm
  if (timeout_alarm_id > 0) {
    cancel_alarm(timeout_alarm_id);
  }

  // Set a new alarm for the timeout
  timeout_alarm_id = add_alarm_in_us(timeout, timeout_callback, NULL, false);
  printf("[glitcher] Alarm set for %u microseconds.\n", timeout);
}

The timeout_callback simply calls the set_target_off function.

Glitching Signal

The element that is covered in this section.
The element that is covered in this section.

The glitching signal controls when the OTP_VDD glitch should happen. The goal is to place the glitch with microsecond precision when the CRITICAL register is read, as shown below.

Glitching timeline.
Glitching timeline.

We need a way to precisely control the timing of the glitch relative to another signal and to move it in 5 μs steps, because we don’t know the exact time the glitch should occur, so we sweep it over a specific range until it succeeds.

The Analog Discovery 2 used by Aedan has a digital output pin that he used as a power source to precisely inject the glitch, which is very convenient. My PicoScope doesn’t have that digital output, but it does include an Arbitrary Waveform Generator (AWG) which could be used for the same purpose—if not for the following limitations:

  • The AWG output is limited to ~3.3 mA, and
  • The AWG output maximum voltage is 2 V.

I need 3.3 V to feed the OTP_VDD! Even if the AWG could supply 3.3 V, for a 50 μs glitch and a load of ~20 nF (roughly measured OTP_VDD capacitance) with a 3.3 mA drive strength, the rise/fall time would be ~36 μs—so generating a clean 50 μs glitch would be impractical (best case a glitch of ~72 μs).

There’s nevertheless one key feature from the AWG: precise control of a square pulse. I can create a 50us pulse an move it around with high precision. However, I will need to add an external circuit (i.e., like a buffer) to handle the OTP_VDD capacitive load.

While experimenting, I found that the AWGs are actually quite versatile. For example, an AWG signal can be configured to start only at a specific trigger, and it’s possible to generate a signal based on a binary sequence. It was a scope feature I had not used before, so I thought it was a good opportunity to learn more about it.

For example, if I configure the AWG so that each bit takes 5 μs and use the following waveform:

1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1...

It will generate a signal that is high for 4×5 μs, low for 10×5 μs, and high again. The official PicoScope Python SDK is not very practical (it’s like a direct binding to the C code), so I used Collin’s PicoScope Python library (this is changing, though). I created a basic script to programmatically change the sequence and, in this case, shift where the signal drop starts.

The relevant code snippets are the following.

(...)
AWG_N = 512
(...)
awg_freq = int(781/2)
log(f"AWG frequency set to {awg_freq} Hz")
deltaPhase = ps.getAWGDeltaPhase(awg_freq, indexMode=0, bufferLength=AWG_N)
(...)
wave = build_drop_wave(low_bits, ofs, 0, mx, AWG_N, inverted=...)
(...)
ps.setSigGenArbitrary(
    waveform=wave,
    deltaPhase=deltaPhase,
    offsetVoltage=0,
    pkToPk=2.0,
    indexMode=0,
    shots=1,
    triggerType=ps.SIGGEN_TRIGGER_TYPES.get('Rising', 0),
    triggerSource=ps.SIGGEN_TRIGGER_SOURCES.get('ScopeTrig', 1)
)
(...)
...

I set the AWG trigger to be the same as the scope trigger. This means that when the scope triggers, the AWG will output the waveform once shortly after. The build_drop_wave function generates the wave bitstream, shown below.

def build_drop_wave(low_bits: int, offset: int, mn: int, mx: int, 
  n: int = AWG_N, inverted: bool = False) -> np.ndarray:
    if not inverted:
        w = np.full(n, mx, dtype=np.int16)
        if low_bits > 0:
            for k in range(low_bits):
                w[(offset + k) % n] = np.int16(mn)
    else:
        # Inverted: baseline low and insert a HIGH pulse
        w = np.full(n, mn, dtype=np.int16)
        if low_bits > 0:
            for k in range(low_bits):
                w[(offset + k) % n] = np.int16(mx)
    return w

So, let’s recap what we have so far.

  • The RP2350 target was prepared to allow capturing OTP_VDD current (and also to glitch it when the glitching circuit was ready);
  • The current-measuring circuit is working;
  • The OTP reads were located (~1.4 ms from reset);
  • A target-control Pico 2;
  • A glitching signal that drops at a configurable time after target reset.

Now we’re just missing two things:

  • The glitching buffer/driver
  • The attack “orchestrator” — a script that connects the different pieces (configuring the glitch signal, sweeping, resetting the target via the additional Pico 2) and executes the attack.

Let’s continue with the glitching circuit.

Glitching Circuit

The element that is covered in this section.
The element that is covered in this section.

The glitching circuit in this case is one that makes OTP_VDD voltage drop to zero very quickly. To create this glitching circuit, I tried to make use of an old assorted kit of BJT transistors I have. The kit has some classic transistors such as the 2N2222, and 2N3904 (NPN), 2N3906 (PNP), among others.

I did some quick searches on the internet for BJT inverter and buffer circuits. The first circuit I came across was the following.

Simplistic BJT glitching circuit.
Simplistic BJT glitching circuit.

The simulation looked promising… let’s see in reality. First I assembled the circuit on a breadboard as shown below.

Glitching circuit in a breadboard
Glitching circuit in a breadboard

Then using my Picoscope and triggering the glitch with a switch, I captured the voltage drop I could get with this circuit.

Glitching circuit real performance.
Glitching circuit real performance.

Well, not so great; I was expecting a steep drop to zero, hold for 50 μs, then rise again to 3.3 V. Looking at the voltage drop on OTP_VDD we can see a damping effect from the capacitive load instead of a quick drop. This means I didn’t properly model my load (C1 and R6 in the schematic). Taking two points from this damping curve and fitting an RC discharge formula gives a capacitance of ~19 nF, compared to the 0.1 nF I used in the model — a big difference.

I tried multiple adjustments and different BJT circuits but couldn’t make them work for this purpose. At that point I had to decide whether to continue or go for plan B: buy a power-switch NMOS, which would simplify the design. I chose plan B, but the analog-design challenge was interesting, so I decided to revisit it after the RP2350 experiment.

I chose to buy the IRLML2502TRPBF in an SOT23 package and some SOT23-to-DIP adapter boards from AliExpress (~5€ for 20 transistors + adapters including shipping).

Soldered glitching circuit PCB.
Soldered glitching circuit PCB.

The way to connect the glitching circuit is simply connecting the gate to the trigger signal (AWG output) and the drain to the OTP_VDD, as illustrated below.

Power transistor connection.
Power transistor connection.

Doing the same capture that I did previously, it looks like the following.

NMOS Glitching circuit real performance.
NMOS Glitching circuit real performance.

Much better. The NMOS can drop the OTP_VDD very quickly; while recharge is slower, I can reduce the pulse width to get closer to a 50 μs pulse.

Automation & Orchestrator

Now that we have all the hardware elements, we need the attack orchestrator on the host. This can be a simple Python script that initializes the hardware and sweeps the glitch until we hit a successful event that causes the RP2350 to boot the RISC-V.

Glitching script flow.
Glitching script flow.

An example attack command looks like this:

python pico-otp-glitcher.py --start 320 --end 340 --trigger-delay-us 1400 \
--bw-cutoff-hz 250e3 --samples 10000 --duration 1000 --retries 20 \
--serial-port /dev/tty.usbmodem143201 --awg-pkpk 4 \
--drop-inverted --low-bits 2

The full code is shared at the end of the post.

Debugging & Failed Attempts

The first attempts at the glitch sweep did not produce any interesting effects. My earliest attempts used the BJT-based glitcher, before I had the NMOS device. The results are shown in the animation below.

Glitch sweep using bad glitcher circuit.
Glitch sweep using bad glitcher circuit.

During this capture I did not see any effect on the target. The OpenOCD outputs were saved for every capture. For example:

$ cat sweep_ofs300_try06.txt
=== STDOUT ===
(no stdout)

=== STDERR ===
Open On-Chip Debugger 0.12.0+dev-00002-gcd4873400 (2025-09-12-12:57)
Licensed under GNU GPL v2
For bug reports, read
	http://openocd.org/doc/doxygen/bugs.html
Warn : Transport "swd" was already selected
Info : [rp2350.cm0] Hardware thread awareness created
Info : [rp2350.cm1] Hardware thread awareness created
ocd_process_reset_inner
Warn : An adapter speed is not selected in the init scripts. OpenOCD ...
Warn : To remove this warnings and achieve reasonable communication ...
Info : J-Link V9 compiled Sep  1 2016 18:29:50
Info : Hardware version: 9.60
Info : VTarget = 3.269 V
Info : clock speed 100 kHz
Info : SWD DPIDR 0x4c013477 DPv3
Info : [rp2350.cm0] Cortex-M33 r1p0 processor detected
Info : [rp2350.cm0] target has 8 breakpoints, 4 watchpoints
Info : [rp2350.cm0] Examination succeed
Info : [rp2350.cm1] Cortex-M33 r1p0 processor detected
Info : [rp2350.cm1] target has 8 breakpoints, 4 watchpoints
Info : [rp2350.cm1] Examination succeed
Info : [rp2350.cm0] starting gdb server on 3333
Info : Listening on port 3333 for gdb connections


=== RETURN CODE: 0 ===

Comparing all outputs, I saw no differences in the size of the OpenOCD outputs. Nothing interesting.

...
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try00.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try01.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try02.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try03.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try04.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try05.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try06.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try07.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try08.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs336_try09.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs337_try00.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs337_try01.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs337_try02.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs337_try03.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:00 sweep_ofs337_try04.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:01 sweep_ofs337_try05.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:01 sweep_ofs337_try06.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:01 sweep_ofs337_try07.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:01 sweep_ofs337_try08.txt
-rw-r--r--  1 jemos  staff  1246 Oct 21 23:01 sweep_ofs337_try09.txt
...

Then I made some adjustments to the BJT glitcher to try to improve it.

Failed attempt in Nov 22.
Failed attempt in Nov 22.

Another circuit, another try:

Adjusted BJT but still failed.
Adjusted BJT but still failed.

OK, then I got the NMOS power transistor and improved the glitching circuit. The first capture looked like this:

Using NMOS but it is still failing.
Using NMOS but it is still failing.

However, with the NMOS I started to get some glitches. The OpenOCD outputs started to vary in some random offsets:

$ /bin/ls -l *.txt | grep -v 2308
-rw-r--r--  1 jemos  staff   824 Jan 14 21:58 sweep_ofs322_try08.txt
-rw-r--r--  1 jemos  staff  2507 Jan 14 21:58 sweep_ofs323_try02.txt
-rw-r--r--  1 jemos  staff  1350 Jan 14 21:59 sweep_ofs324_try06.txt
-rw-r--r--  1 jemos  staff  2384 Jan 14 21:59 sweep_ofs325_try02.txt
...

The output had for example:

...
Info : clock speed 4000 kHz
Info : SWD DPIDR 0x4c013477 DPv3
Info : SWD DPIDR 0x4c013477 DPv3
Error: Failed to read memory at 0xe000ed00
Error: [rp2350.cm0] Execution of event check-availability failed:
...

At some point I realized I could be doing one thing wrong. Aedan’s slides show he used the J-Link Windows binary; that J-Link software behaves differently from OpenOCD. From what I understand, OpenOCD will not enumerate TAP devices — it tries to connect to the device specified in the configuration. That means you must tell OpenOCD whether to expect the ARM or the RISC-V processor. If the attack succeeds but I run OpenOCD with the ARM configuration, OpenOCD will fail, and it won’t be clear whether the failure was due to a bad glitch or because the device actually booted the RISC-V.

Thus, I had to change the OpenOCD command to assume the RISC-V was booted instead. The command is:

# Old
./openocd -s tcl -f interface/jlink.cfg -c "transport select swd" \
-f "target/rp2350.cfg" -c "init; targets; exit"

# New
./openocd -s tcl -f interface/jlink.cfg -c "transport select swd" \
-f "target/rp2350_riscv.cfg" -c "init; targets; exit"

With this change the outputs looked like this:

...
Info : [rp2350.rv0] datacount=1 progbufsize=2
Info : [rp2350.rv0] unavailable.
Error: [rp2350.rv0] Examination failed
Warn : target rp2350.rv0 examination failed
...

It wasn’t working… Sweeping the glitch did not cause it to boot the RISC-V. There were some changes in the output, but not the result I was looking for.

The Real Problem: Jitter

Until now, I had been using a reset-based trigger. When the target resets, that’s when the timer starts counting to schedule the glitch. The glitch happens ~1.4 ms + 250 μs after reset. Look at the following capture and notice how the blue spike (when OTP reads start) and the glitch (orange signal drop) are not aligned across different retries for the same glitch offset (off=334, 335, etc.). As shown, there was noticeable jitter; the interval between the OTP read start and the glitch did not shift smoothly — it moved back and forth.

Failed attempt in Nov 22.
Failed attempt in Nov 22.

Initially I overlooked this. I assumed that by shifting the glitch it would eventually hit the right time, even with jitter. The final step to make this attack work was to use the same glitch trigger as Aedan’s: the OTP-read-start spike.

I configured the scope trigger for a rising edge crossing 1.51 V on probe A. The result is shown below.

Successful attempt.
Successful attempt.

The glitch now shifts in a much more consistent way. I reduced the pulse width to ~35–40 μs because the rising and falling edges were not sharp.

That was the turning point. After reaching offsets 46–47, the glitch caused the RP2350 to boot the RISC-V!

$ python pico-otp-glitcher.py --start 46 --end 47 --bw-cutoff-hz 500e3 --samples 10000 \
--duration 800 --retries 10 --serial-port /dev/tty.usbmodem143301 --awg-pkpk 4 \
--drop-inverted --low-bits 3

[2026-01-25 23:50:57.540] Opening PicoScope 2000A...
[2026-01-25 23:51:01.273] Configuring channels A, B, C, D: DC, ±5V
[2026-01-25 23:51:01.274] Requesting a sampling period of 0.080 µs for a total duration of 800.0 µs
[2026-01-25 23:51:01.276] Sampling interval set: dt=0.72us, samples=11111 (max 8388585), pretrig=5%
[2026-01-25 23:51:01.276] Setting trigger on A rising at 0V (scope)
[2026-01-25 23:51:01.277] AWG code range unavailable, using int16 fullscale
[2026-01-25 23:51:01.277] Sweep offsets: start=46, end=47, low_bits=3, drop_inverted=True
[2026-01-25 23:51:01.277] AWG frequency set to 390 Hz
Calculated deltaPhase for 390 Hz: 1309 (capdur=8.0e-04s, n=512)
[2026-01-25 23:51:01.278] Building waveform for offset 46
[2026-01-25 23:51:01.280] Wave: (...)
[2026-01-25 23:51:01.281] Programming AWG for offset 46
[2026-01-25 23:51:06.289] Offset 46: capture 1/10
[2026-01-25 23:51:06.290] Running block capture (arming scope)...
[2026-01-25 23:51:06.454] Sending trigger to Pico2...
[2026-01-25 23:51:06.454] Opening serial port /dev/tty.usbmodem143301 @ 115200 baud...
[2026-01-25 23:51:06.673] [glitcher] Sending 'off' command first to clear previous on state.
[2026-01-25 23:51:06.674] [glitcher] Sending 'on' command with timeout 600000000 us...
[2026-01-25 23:51:06.674] [glitcher] Waiting for response...
[2026-01-25 23:51:06.679] [glitcher] Responded with: b'f'
[2026-01-25 23:51:06.683] [glitcher] Responded with: b'[glitcher] Target manually turned off.'
[2026-01-25 23:51:06.683] [glitcher] Responded with: b'> o 600000000'
[2026-01-25 23:51:06.684] [glitcher] Responded with: b'[glitcher] Timeout set to 600000000 microseconds.'
[2026-01-25 23:51:06.685] [CHECK] Response contains expected line: PASS
[2026-01-25 23:51:06.689] Fetching data from scope...
[2026-01-25 23:51:21.093] Running OpenOCD for capture and writing output to captures/sweep_ofs046_try00.txt
RISC-V core booted... try to connect to it via gdb...

Now I launch OpenOCD manually to open a GDB port:

Open On-Chip Debugger 0.12.0+dev-00002-gcd4873400 (2025-09-12-12:57)
Licensed under GNU GPL v2
For bug reports, read
	http://openocd.org/doc/doxygen/bugs.html
Warn : Transport "swd" was already selected
Info : [rp2350.rv0] Hardware thread awareness created
Info : [rp2350.rv1] Hardware thread awareness created
ocd_process_reset_inner
Info : J-Link V9 compiled May  7 2021 16:26:12
Info : Hardware version: 9.60
Info : VTarget = 3.262 V
Info : clock speed 100 kHz
Info : SWD DPIDR 0x4c013477 DPv3
Info : [rp2350.rv0] datacount=1 progbufsize=2
Info : [rp2350.rv0] Disabling abstract command reads from CSRs.
Info : [rp2350.rv0] Disabling abstract command writes to CSRs.
Info : [rp2350.rv0] Core 0 could not be made part of halt group 1.
Info : [rp2350.rv0] Examined RISC-V core
Info : [rp2350.rv0]  XLEN=32, misa=0x40901105
Info : [rp2350.rv0] Examination succeed
Info : [rp2350.rv1] datacount=1 progbufsize=2
Info : [rp2350.rv1] Disabling abstract command reads from CSRs.
Info : [rp2350.rv1] Disabling abstract command writes to CSRs.
Info : [rp2350.rv1] Core 1 could not be made part of halt group 1.
Info : [rp2350.rv1] Examined RISC-V core
Info : [rp2350.rv1]  XLEN=32, misa=0x40901105
Info : [rp2350.rv1] Examination succeed
Info : [rp2350.rv0] starting gdb server on 3333
Info : Listening on port 3333 for gdb connections
    TargetName         Type       Endian TapName            State       
--  ------------------ ---------- ------ ------------------ ------------
 0* rp2350.rv0         riscv      little rp2350.cpu         running
 1  rp2350.rv1         riscv      little rp2350.cpu         running
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : accepting 'gdb' connection on tcp/3333
rp2350.rv0 halted due to debug-request.
rp2350.rv1 halted due to debug-request.
Info : RP2350 rev 2, QSPI Flash win w25q128fv/jv id = 0x1840ef 
size = 16384 KiB in 4096 sectors
Info : New GDB Connection: 1, Target rp2350.rv0, state: halted

Finally, with a GDB that supports the RISC-V architecture, we connect to the OpenOCD session and read the OTP secrets at the memory-mapped address 0x40137020:

$ riscv64-elf-gdb
GNU gdb (GDB) 17.1
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "--host=x86_64-apple-darwin22.6.0 --target=riscv64-elf".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word".
(gdb) set remotetimeout 60
(gdb) target remote localhost:3333
Remote debugging using localhost:3333
⚠️ warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x00007640 in ?? ()
(gdb) x/32wx 0x40137020
0x40137020:	0x0022c0ff	0x0014ffee	0x0022c0ff	0x0014ffee
0x40137030:	0x0022c0ff	0x0014ffee	0x0022c0ff	0x0014ffee
0x40137040:	0x00000000	0x00000000	0x00000000	0x00000000
0x40137050:	0x00000000	0x00000000	0x00000000	0x00000000
0x40137060:	0x00000000	0x00000000	0x00000000	0x00000000
0x40137070:	0x00000000	0x00000000	0x00000000	0x00000000
0x40137080:	0x00000000	0x00000000	0x00000000	0x00000000
0x40137090:	0x00000000	0x00000000	0x00000000	0x00000000
(gdb) x/1wx 0x40120148
0x40120148:	0x00030033

Conclusion

Reproducing this attack was less about generating a stronger glitch and more about understanding the system’s timing and measurement constraints. The breakthrough did not come from sharper edges or more aggressive pulses, but from choosing the right trigger and observing the system correctly.

Along the way, tooling assumptions, instrumentation limitations, and small architectural details proved just as important as the glitch circuit itself. What looked at first like hardware limitations often turned out to be measurement or synchronization problems.

This is probably obvious, but investing time (and in some cases money) in a solid hardware setup leads to smoother, more predictable experiments. For example, the custom target board to access OTP_VDD directly, eliminated the need for invasive hardware modifications (i.e., PCB reworking, cutting PCB traces).

This turned into a multi-month experiment that I pursued intermittently in my spare time. On more than one occasion, I thought about giving up. For me, the key was to resist starting something new before bringing this project to completion.

Yet the journey itself was worth it — I learned a great deal along the way.

Project Files

Note: these files were not edited for general use, they were not cleaned up. Consider them just as a referrence.