Dumping a Reolink E1 Pro Firmware from the flash chip

  1. Opening the Device
  2. Missing Tools
  3. Dumping the flash
  4. Understanding the SPI NAND Flash
  5. xfer vs read/write bytes()
  6. Clocking
  7. Why dummy bytes are needed
  8. Unsuccessful data read
  9. Writing basic instructions
  10. Using a Logic Analyzer
  11. Power problems
  12. Reading data out
  13. Why we chose Buffer Read Mode
  14. Dumping the Firmware
  15. Extracting the Filesystem
  16. Finishing words
  17. Final script

This post documents my first hardware hacking project: dumping the firmware from a Reolink E1 Pro security camera. Just to be clear, I was (and still am) a complete beginner when it comes to hardware hacking. I did this together with my roommate, who had slightly more experience than me and already owned some basic hobbyist hardware tools.

The goal wasn’t to find vulnerabilities or exploit the device. The goal was simply to get the firmware out and understand how we could use the SPI protocol to do so. Yessss, along the way we made a lot of mistakes, misunderstood several concepts, but we learned a lot. I apologize in advance to all of you hardware hackers out there, this post might make you cry.

Anyhoo, we will focus on the process, including the parts that didn’t work at first.

Opening the Device

The first step was physical: opening the camera.

Once opened, we removed the main PCB and started identifying components. The two important parts for us were:

  • The main System on a Chip (SoC), which we didn’t identify in detail
  • An external SPI NAND flash chip, which we assumed contained the firmware

We looked for easy wins first: UART headers, JTAG, or any exposed debug ports. Unfortunately, there were no obvious or populated debug interfaces available… (at least none that we could confidently identify).

Since there was no easy software or debug access, we decided to go after the firmware directly via the flash chip.

Missing Tools

At this point, we ran into a very classic hardware hacking problem: we didn’t have the right tools. This quickly became a recurring theme throughout the project :’).

Because we were only simpleton hardware hacking hobbiest, our toolset was limited. My roommate had some stuff from a previous project, but unlike here, he had a UART debug port on his previous project. However, he had 4 stabilized probe needles, but we needed 8 so that wasn’t going to work.

Our next idea was then to connect to the SPI NAND flash using a basic alligator clip setup (that we also had to buy). In theory, this should have worked. In practice, it didn’t. The clips wouldn’t clip onto the teeny-tiny soldered pins of the flash chip, so we literally couldn’t read any data.

alligator clip

So, back to ordering more hardware.

We ended up buying 4 more stabilized probe needles (from PCbite), which allowed us to precisely and reliably contact each of the eight pins on the flash chip individually.

On top of that, we didn’t even have a proper magnifying glass or microscope. Reading the tiny markings on the chip meant straining our eyes and awkwardly zooming in with our phone cameras.

This project taught us that a large part of hardware hacking is having to delay your project by a few days by having to acquire new tools.

Dumping the flash

With no UART or debug access, dumping the SPI NAND flash directly made the most sense.

The idea was simple in theory:

  • Identify the flash chip
  • Look up its datasheet
  • Communicate with it over SPI
  • Read out the contents

First things first, really squint our eyes to read the flash’s chip model.

WINBOND W25N512GVxIG/IT
3V 512M-BIT
SERIAL SLC NAND FLASH MEMORY WITH
DUAL/QUAD SPI
BUFFER READ & CONTINUOUS READ

We found the datasheet on the internet, and proceeded to look for the pad configuration. This tells us how to setup our pins to the Raspberry Pi GPIO Pinout

https://pinout.xyz/

With a bit of AI magic, we figured out how to pin each of them correctly.

Chip PinChip FunctionRPi GPIO PinRPi Function
1/CSPin 24CE0 (GPIO 8)
2DO (IO1)Pin 21MISO (GPIO 9)
3/WP (IO2)Pin 173.3V*
4GNDPin 20GND
5DI (IO0)Pin 19MOSI (GPIO 10)
6CLKPin 23SCLK (GPIO 11)
7/HOLD (IO3)Pin 173.3V*
8VCCPin 173.3V

Ben Eater’s video helped us a lot understand concepts and how the SPI protocol worked.

Understanding the SPI NAND Flash

Our original idea was to dump the flash using flashrom. However, we didn’t find support for this particular chip, so we had to manually implement the SPI commands ourselves based entirely on the datasheet.

This required:

  • Identifying command opcodes (read ID, read page, etc.)
  • Understanding addressing and page layouts
  • Knowing when and how data would be returned

xfer vs read/write bytes()

Another major conceptual hurdle was understanding how SPI reads actually work.

SPI is full-duplex: for every byte clocked out on MOSI (master out, slve in), a byte is clocked in on MISO (master in, slve out). This means that even when you want to read data, you must still send data to generate clock pulses.

In practice:

  • A read command is sent
  • Address bytes are transmitted
  • Dummy bytes are clocked out
  • Real data is clocked back in on MISO

If you want to read 100 bytes, you must transmit 100 dummy bytes to clock them out.

This new understanding also explained something that had confused us earlier when using the Python spidev library.

Initially, we tried using higher-level helper functions like writebytes() and readbytes(). Intuitively, these felt like the “right” functions to use: write a command, then read data back.

However, this didn’t work at all for us.

What did work was using xfer2(), which performs a single full-duplex SPI transaction, sending and receiving bytes simultaneously.

Digging deeper, we ended up reading parts of the SPI driver and spidev source code itself. One particular comment stood out: SPI transfers are inherently full-duplex, and separating writes and reads into distinct calls can break devices that expect a continuous transaction with chip select held active.

/*
This supports access to SPI devices using normal userspace I/O calls.
Note that while traditional UNIX/POSIX I/O semantics are half duplex, and often mask message boundaries, full SPI support requires full duplex transfers.  There are several kinds of internal message boundaries to handle chipselect management and other protocol options.
 */

In other words:

  • writebytes() sends data but ignores incoming bytes
  • readbytes() clocks data but doesn’t send meaningful command bytes
  • xfer2() does both at the same time, which is what the flash actually expects

Once we switched entirely to xfer2() and treated every operation as a single full-duplex exchange (including dummy bytes), communication became consistent.

Example for reading the JEDEC ID:

print(f"jedec ID reply: {bytes(spi.xfer2([CMD_JEDEC_ID, DUMMY_BYTE, DUMMY_BYTE, DUMMY_BYTE, DUMMY_BYTE]) ).hex ()}")

We send the instruction (read the JEDEC ID) and then one dummy byte for the 8 clocks as a data in (DI), and another 3 dummy bytes (8 x 3 clocks) for the value of the JEDEC ID, the data out (DO).

Clocking


The SPI master (our Raspberry Pi) must generate clock pulses for data to come out of the device. No clock = no data.

The NAND chip’s datasheet says:

When /CS is brought low the device will be selected, power consumption will increase to active levels and instructions can be written to and data read from the device.

The SPI Serial Clock Input (CLK) pin provides the timing for serial input and output operations.

In our setup, using the SPI driver from Linux on a Raspberry Pi, the spidev driver defaults to CS being active on low.

TL;DR: CS needs to be low and the clock toggling in order to be able to read/write data from and to the chip.

Why dummy bytes are needed

Each byte is 8 clock pulses.

If you want to read 1 byte, you need to generate 8 clock pulses. To generate those pulses, the master (in this case the Raspberry Pi) must send something on MOSI.

So the master sends a dummy byte (often 0x00 or 0xFF):

  • MOSI: meaningless data
  • SCLK: toggles
  • MISO: real data from the flash

Example:

  • Send 100 dummy bytes → generate 800 clock pulses
  • Receive 100 real bytes from MISO

Unsuccessful data read

We used a Raspberry Pi to communicate with the flash and relied on Linux’s spidev interface from Python.

In short, spidev is a Linux kernel driver that exposes SPI devices to userspace, allowing applications to perform raw SPI transfers without writing kernel drivers.

Using some claude vibe-coding, Python and the spidev library, we started sending commands to the flash.

However… nothing but zeros.

Our first attempts were not very successful.

The vibe code was reading some stuff, like the JEDEC ID (which we could validate via the datasheet), but when trying to read data from the data buffer, it was just returning all 0’s.

JEDEC ID and Status Register returns exepcted values

After countless debugs and print(), we finally decided to debug it properly.

As you can see we tried multiple times to get a reading… unsuccessfully

Writing basic instructions

Before we came across the reason why we weren’t receiving any data (aka when we were being dummy bytes), we decided to write our own script that did the most basic stuff like reading the JEDEC ID, reading the status register, and re-writing the status register (e.g. setting BUF = 0 to try the continuous reading mode).

When reading the datasheet more carefully, we stumbled across two reading mode, “buffered” and “continuous”. I will be covering that later. However, the way we can control that read mode is by sending a write instruction to MOSI (master out, slve in) to re-write the status register and change the value of BUF.

As an example, if we just read the status register, we get back a 8 bit integer (which we decided to represent as a hexadecimal value for clarity):

Diagram for reading status register

To read the status register, we send the instruction 0x0F or 0x05 (read instruction), and then send the Status Register Address (SR Address).

Then, we send 1 dummy byte (8 clock cycles) for the value that will be returned (I cover why in the dummy byte section).

CMD_READ_STATUS_REG = 0x05
STATUS_REG_ADDR = 0xB0

spi.xfer2([CMD_READ_STATUS_REG, STATUS_REG_ADDR, DUMMY_BYTE])

We received 0x1D as a return value, which when interpreted in binary, we get
0001 1101.

Comparing to the diagram below, we can see that the BUF bit is set to 1.

Similarly, we can set a new value for BUF by sending in the desired hexadecimal value to modify just that one bit:

Flipping one bit for the BUF value to 0

Sample python code to read and write to the Status Register:

CMD_READ_STATUS_REG = 0x05
CMD_WRITE_STATUS_REG = 0x1F
STATUS_REG_ADDR = 0xB0
NEW_VALUE = 0x15

stat_reg_value = spi.xfer2([CMD_READ_STATUS_REG, STATUS_REG_ADDR, DUMMY_BYTE])
print(stat_reg_value)
spi.xfer2([CMD_WRITE_STATUS_REG, STATUS_REG_ADDR, NEW_VALUE])

Using a Logic Analyzer

We connected a Saleae logic analyzer to the SPI lines (CS, CLK, MOSI, MISO) to see what was actually happening on the wire.

This helped confirm that:

  • SPI traffic was being generated
  • Commands were being sent
  • We received the valid values for the JEDEC ID and Status Register
  • But we still received 0x00 as data read from the flash

Everything looked good. We got basic information such as the JEDEC ID and Status Register, yet the data output was still 0’s.

We can see the data being sent and received with the logic analyzer. We are reading the Status Register value and receiving the value 0x15 in return (this is when BUF = 0 is set).

Power problems

Eventually, we stumbled onto a clue by accident.

After a few mental breakdowns trying to figure out why we weren’t receiving any data, we decided to power the camera normally while still probing the flash (it was a bad ideaaaa, do not replicate). However, the data read instructions were suceeding. That immediately suggested that something was wrong with our first power setup.

Originally, we measured the voltage directly at the flash chip’s VCC pin. Instead of 3.3V, we were getting around 2.2V.

We first thought that the issue was due to the resistance on the breadboard. The setup had the flash’s VCC pin connected to the breadboard’s power rail, which in turn was connected to the Raspberry Pi’s 3.3V pin, and we thought that the rail might introduced enough resistance (or some other mysterious unknown reason*) to cause a significant voltage drop.

Once we moved the VCC connection closer to the Raspberry Pi’s 3.3V output on the rail, the voltage at the flash jumped to a stable 3.3V.

Now we had all CS, MOSI, and MISO idling at high while no data was being transmitted.

This caught us off guard because the data sheet of the NAND doesn’t have any requirement regarding the state of the MOSI and MISO lines when idle, and more importantly, all of the previous SPI communications we had issues with had the MOSI and MISO lines idling low, which didn’t seem to be problematic at first (because the JEDEC instruction would reply correctly).

Ironically, we only discovered this because the flash had been back-powered when we connected the power back to the chip. Again, we are beginners, so shit happens. Luckily, nothing broke or exploded.

*noob moment

So it turns out the power rails on our breadboard were split in half, meaning the VCC pin of the NAND flash was not actually connected to the Raspberry Pi’s 3.3 V output. We confirmed this using a continuity test on a multimeter. As a result, the flash had been under-powered the entire time. Our “3 V” rail wasn’t delivering 3 V at all, and the chip was instead partially powering itself through one of the SPI signal lines (most likely CS). 

To verify our findings, here is actual proof from the board makers themselves:

Reading data out

Before you can read data from the flash, the chip first needs to load a page from its internal memory into a temporary data buffer. After power-up, this is already done automatically for page 0, so the device is immediately ready for basic read commands.

This is done by sending a Page Data Read instruction.

After the 2,112 bytes of page data are loaded into the Data Buffer, several Read instructions can be issued to access the Data Buffer and read out the data.

How to find the Page Address

As per the data sheet, the PA is 16-bits (which is two bytes) in big endian. Because SPI can only transmit 8 bits at the time, we need to send the high byte and then the low byte.

So the first page would be [0x00, 0x00], the second page would be [0x00, 0x01], and so on…

Reading the page

spi.xfer2([CMD_PAGE_DATA_READ, DUMMY_BYTE, page_addr_as_bytes[0], page_addr_as_bytes[1]])

Reading the data

Our flash has 32.768 pages. Each page size is 2.112 bytes, so we will send page_size * dummy_byte to clock out data. Then we can loop in range from 0 to 32.768.

NUM_PAGES = 32768

def read_page(page_addr: int) -> bytes:
    page_addr_as_bytes = page_addr.to_bytes(2, byteorder='big')
    spi_sleep() # chill after page read
    spi.xfer2([CMD_PAGE_DATA_READ, DUMMY_BYTE, page_addr_as_bytes[0], page_addr_as_bytes[1]])
    PAGE_SIZE = 2112
    page_contents = bytes(spi.xfer2([CMD_READ_DATA, DUMMY_BYTE, DUMMY_BYTE, DUMMY_BYTE] + [DUMMY_BYTE] * PAGE_SIZE))
    return page_contents

with open("firmware.bin", "wb") as outfile:
    for page in range(NUM_PAGES):
        print(f"reading page {page}")
        outfile.write(read_page(page))

Why we chose Buffer Read Mode

There are two modes values for BUF to read data from the flash chip, being Continuous Read (BUF = 0) or Buffer Read Mode (BUF = 1):

Buffer Read Mode
In this mode, you tell the chip where in the buffer you want to start reading by providing a column (offset) address. The flash will then output data starting from that position. When it reaches the end of the buffer, it simply stops outputting data. This mode is useful when you only want a specific portion of a page.

Continuous Read Mode
In this mode, you don’t need to specify a starting position. The chip always begins reading from the start of the buffer. When it reaches the end of the current page, it automatically continues reading into the next page. This allows you to read large chunks of memory, or even the entire flash, using a single read command.


Our initial idea was to use the continuous read mode (as it’s enabled by default) and would let us dump the entire flash contents with a single read instruction. However, we learned that the SPI transaction size (the read) is limited (often to 4KiB by the kernel driver).

With Continuous Read Mode: Theoretically more efficient, but we would need CS to stay low continuously (as mentioned in the datasheet).

Chip Select (CS) side-note:
CS is like a “listen/ignore” switch for the chip. When CS is low, it means it will listen to instructions and receive “messages”. When CS is high, it will not process any instructions.

If the SPI transaction breaks after 4KiB and CS goes high, the continuous read is interrupted and we would lose our position in the stream.

Notice CS needs to stay at low during the ENTIRE read

With Buffer Read Mode: We can work within this limitation by reading one page at a time, CS goes high between each command, and we would just manually loop through pages, not losing our position in the stream since we would be reading page by page.

We got the idea to check the transaction size due to this comment from the SpiDev documentation:

Similar to writebytes but accepts arbitrary large lists. If list size exceeds buffer size (which is read from /sys/module/spidev/parameters/bufsiz), data will be split into smaller chunks and sent in multiple operations. – Spidev Doc

user:$ cat /sys/module/spidev/parameters/bufsiz
4096

So we decided to go with a BUF = 1, Buffer Read Mode. After loading (reading) the page into the buffer, we could then read from the buffer.

Dumping the Firmware

After fixing the power issues and figuring out which read mode we were going to go with, we finally dumped the entire flash chip and wrote the raw bytes to a file.

We did it, we finally had a ~valid~ firmware image.

Extracting the Filesystem

To validate the dump, we ran it through binwalk.

Now we finally had recognizable filesystem structures. Seeing an actual embedded filesystem extracted from a device we physically dumped ourselves was incredibly satisfying.

At that point, the main goal of the project was achieved.

However, when trying to extract it with binwalk, we ran into some issues. It wasn’t able to successfully extract the UBI image, and was having trouble with some offsets or perhaps some corrupted UBI blocks.

To be completely honest, we did not want to fight the extraction, so we let Claude Code take the wheel here.

Claude Code searched for the UBI erase count header magic number throughout the entire firmware image. Instead of using binwalk’s detected offset (0x328048), we extracted from 0x580000 where the properly aligned UBI blocks began:

dd if=firmware.bin bs=131072 skip=44 count=215 of=ubi_main.bin

ubireader_extract_images -o ubi_extracted ubi_main.bin

unsquashfs -d rootfs_extracted ubi_extracted/ubi_main.bin/img-*-vol-rootfs.ubifs
unsquashfs -d app_extracted ubi_extracted/ubi_main.bin/img-*-vol-app.ubifs
WE HAVE A FILESYSTEM

Finishing words

This project was messy, confusing, and head-banging… but it was also very fun and educational. If you’re a beginner interested in hardware hacking, I can’t recommend this kind of hands-on project enough.

As usual, I learned a lot more from what didn’t work than from what did work.

Final script

import spidev
import time
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("-o", "--output", default="firm.bin")
parser.add_argument("--clock", type=int, default=20000000)
parser.add_argument("--numpages", type=int, default=32768)

args = parser.parse_args()

def spi_sleep():
    time.sleep(0.0005)

CMD_JEDEC_ID = 0x9F
CMD_DEVICE_RESET = 0xFF
CMD_PAGE_DATA_READ = 0x13
CMD_READ_DATA = 0x03
CMD_READ_STATUS_REG = 0x05
CMD_WRITE_STATUS_REG = 0x1F

STATUS_REG_2_CFG_ADDR = 0xB0
STATUS_REG_2_BUF_MASK = (1<<3)
STATUS_REG_3_ADDR = 0xC0
STATUS_REG_3_ECC1_MASK = (1<<5)
STATUS_REG_3_ECC0_MASK = (1<<4)

DUMMY_BYTE = 0x00

spi = spidev.SpiDev()
spi.open(0, 0)

spi.max_speed_hz = args.clock
spi.mode = 0


spi.xfer2([CMD_DEVICE_RESET])
spi_sleep() # chill after reset

print(f"jedec ID reply: {bytes(spi.xfer2([CMD_JEDEC_ID, DUMMY_BYTE, DUMMY_BYTE, DUMMY_BYTE, DUMMY_BYTE])).hex()}")


def set_buf_mode(enable: bool):
    cfg_reg = spi.xfer2([CMD_READ_STATUS_REG, STATUS_REG_2_CFG_ADDR, DUMMY_BYTE])[2]
    print(f"read status reg 2: {hex(cfg_reg)}")
    if bool(cfg_reg & STATUS_REG_2_BUF_MASK) != enable:
        if enable:
            cfg_reg |= STATUS_REG_2_BUF_MASK
        else:
            cfg_reg &= ~STATUS_REG_2_BUF_MASK
        print(f"writing to status reg 2 with BUF={int(enable)}: {hex(cfg_reg)}")
        spi.xfer2([CMD_WRITE_STATUS_REG, STATUS_REG_2_CFG_ADDR, cfg_reg])
    else:
        print(f"BUF already set to {int(enable)}")


set_buf_mode(False)


def read_page(page_addr: int) -> bytes:
    page_addr_as_bytes = page_addr.to_bytes(2, byteorder='big')
    spi_sleep() # chill after page read
    spi.xfer2([CMD_PAGE_DATA_READ, DUMMY_BYTE, page_addr_as_bytes[0], page_addr_as_bytes[1]])
    PAGE_SIZE = 2112
    INSTRUCTION_INPUT_LENGTH  = 4
    page_contents = bytes(spi.xfer2([CMD_READ_DATA, DUMMY_BYTE, DUMMY_BYTE, DUMMY_BYTE] + [DUMMY_BYTE] * PAGE_SIZE)[INSTRUCTION_INPUT_LENGTH:-64])
    assert(len(page_contents) == 2048)

    status_reg_3 = spi.xfer2([CMD_READ_STATUS_REG, STATUS_REG_3_ADDR, DUMMY_BYTE])[2]
    if status_reg_3 & (STATUS_REG_3_ECC1_MASK|STATUS_REG_3_ECC0_MASK) != 0:
        print(f"ECC failure: {hex(status_reg_3)}")
        exit(1)
    return page_contents


with open(args.output, "wb") as outfile:
    for page in range(args.numpages):
        print(f"reading page {page}")
        outfile.write(read_page(page))

print("done")

Leave a Reply

Discover more from pamoutaf

Subscribe now to keep reading and get access to the full archive.

Continue reading