One bit in RAM
A 120 GB SSD reported itself as SATAFIRM S11 and served nothing but zeros. Every guide on the internet said to reflash it, which would have destroyed the data. The entire fault was one bit in a RAM flag, set at every boot because of a single page on the NAND.
The drive was a Silicon Power M56, 120 GB, M.2 SATA, with a Phison PS3111-S11 controller and no DRAM. It had been working, and then it had not. What the host saw was this:
Model Number: SATAFIRM S11
Firmware Revision: SBFM21W1
That is not the drive's model. It is not even a real firmware version. dd read 120 GB of zeros from it without a single I/O error. The partition table was gone, the filesystem was gone, and smartctl made the machine hang during boot at a consistent 98.64 seconds, with hardreset failed and ata3.00: disable device.
Search for SATAFIRM S11 and the answer you get, everywhere, is: download Phison's MPTool, pick the matching flash-ID configuration, and reinitialise the controller. This works. It also wipes the flash translation layer and, with it, every byte of your data, permanently. The forum posts say so in passing, usually after the person has already done it.
I wanted the data. So instead of reflashing, I spent a while finding out what SATAFIRM S11 actually is.
It is this: a byte at address 0x5C0CB5F0 in the controller's RAM. If that byte is non-zero, the firmware's IDENTIFY handler reports SATAFIRM S11 instead of the real model. The byte was 0x04 — one bit set — and the bit was set at every boot by the firmware's own startup scan, which had found one bad page in the system area and remembered it. Erasing the block that held that page ended the condition permanently. The drive came back as SPCC M.2 SSD / SBFM21.1, mounted its NTFS volume, and a full image taken from it boots Windows to the desktop.
Getting there took two serious wrong turns and a 105 GB raw dump that I did not end up needing. The wrong turns are the interesting part, so they are in here too.
Standing on Psychite
None of this would have been possible without Psychite by trulycrisp, and the accompanying writeup Drive Firmware Security – Phison S11. That project worked out the genuinely hard part: the vendor-unique command (VUC) transport that gets you past the standard ATA command set and into the controller, the command set itself, and the firmware container format.
With Psychite you can, over an ordinary SATA cable, on a drive that is refusing to serve data:
- read and write the controller's RAM;
- read raw NAND pages, with their out-of-band (OOB) spare bytes;
- erase a NAND block;
- soft-reset the controller so that firmware init runs again;
- pull and decrypt the firmware image.
That is a debugger attached to a dead disk. Everything below is just using it.
Act one: 105 GB of nothing
My first instinct was the obvious one — forget the controller, read the flash directly and rebuild the volume from the raw pages. The OOB bytes on each page carry the LBA of the data in it, so in principle you can sort 8768 blocks of pages into a disk image and mount the result.
I dumped the whole chip. 105 GB, zero read errors, which was already informative: nothing was physically wrong with this NAND. Then I spent a long time failing to turn it into a filesystem, for two reasons that both turned out to be my own.
The CE encoding was wrong
The chip has four chip-enables. Psychite selects one by the bit position within the controller's ce_bitmap, which gives 0, 4, 8, 12. Two of those four values silently do the wrong thing: they re-read CE0 and CE1 at page+2 instead of selecting CE1 and CE3. The OOB carries a CE id, so this is checkable, and once checked the real encoding falls out:
ce_raw = (((address >> 2) & 1) << 3) | ((address >> 3) & 1) # 0->0, 4->8, 8->1, 12->9
The read mode was wrong, and this one hid the fault
This controller uses some blocks in pSLC mode (one bit per cell, 512 pages per block) and the rest in MLC (two bits, 1024 pages). You have to ask for the right one. A pSLC block read in MLC mode does not return garbage — it returns what looks like an erased block.
So my dump quietly classified 4662 of 8768 blocks as empty. They were not empty. They held 38 GB of user data, the entire firmware system area, and — as it turned out — the one page that was causing the whole problem. The same mistake also produced a phantom symptom I believed for days: "the controller hangs when you read system blocks." It does not. It hangs when you read a pSLC system block in MLC mode.
The fix for both was the same, and it is the single most useful habit I took from this: the OOB contains a physical page index. Read a block, check that the page index in the spare bytes matches the order you read them in. If it does not, your addressing or your mode is wrong, and everything you conclude from here is fiction.
Act two: reading the firmware
Meanwhile I pulled the firmware and opened it in Ghidra. The PS3111-S11 runs a little-endian Xtensa core; Ghidra handles it. The one thing you must do is load the sections at their runtime addresses, or every absolute reference is meaningless:
| Section | Load address |
|---|---|
sec0 | 0x5C0E0000 |
sec1 | 0x5C0C8000 |
sec2 | 0x60000400 |
From there it is a short walk. FUN_60004c4c builds the IDENTIFY response. It reports the real model and firmware version unless the byte at 0x5C0CB5F0 is non-zero, in which case it substitutes the SATAFIRM S11 / SBFM21W1 strings.
The byte is not a flag. It is a bitfield of reasons, and each bit has a different setter:
| Bit | Set by | Meaning |
|---|---|---|
| 0 | FUN_6003bde8 | NAND context word at +0xfec non-zero |
| 1 | FUN_60056864 | Hardware pin / register (reg+0x31c bit 9), polarity from config |
| 2 | FUN_60016c4c | System-area scan found an ERROR_UNIT page |
| 3 | — | Read together with bit 2 by FUN_6000892c; setter never located |
This is the part I would most like other people to know, because it changes what you do next. SATAFIRM S11 is not one failure mode. It is at least four, and the controller will tell you which one you have, over read-only commands, in about a second. Every variable that scan writes stays in RAM and can be read without writing anything:
$ sudo safe_dump.py /dev/sgX diag-protect
IDENTIFY model='SATAFIRM S11' firmware='SBFM21W1'
0x5c0cb5f0 [1] 04 protect flag: bit 2, and nothing else
0x5c0ceaac [1] 03 error unit at firmware block 3
0x5c0ce6da [1] 05 system unit header found
0x5c0ce6db [1] 07 block map found
0x5c0ce9ab [1] 08 scan covers firmware blocks 0..7
Exactly one reason bit. And both pieces of FTL metadata — the system unit header and the block map — were found, which means the translation layer was intact. The drive knew where all my data was. It had simply decided not to tell me.
The trap: clearing the flag is not a repair
You can write zero to 0x5C0CB5F0. It takes one command, and IDENTIFY changes to the real model immediately, with no reset. For about a minute this feels like the fix.
It is not. dd still reads zeros. The flag is the symptom; by the time you clear it, every boot-time decision gated on bit 2 has already been taken. Nor does a reset help by itself — it reloads the firmware from NAND, re-runs the scan, finds the same page, and sets the same bit. The only sequence that works is: remove the cause on the NAND, then reset so that init runs again and reaches a different conclusion.
I also tried to be clever and failed twice, which is worth recording:
- Patch out the store instruction at
0x60017003that sets the bit.WRITE_REGISTERcannot write the SRAM code region at0x6001xxxx— it works at0x5C0Cxxxxonly, and the write fails silently. Dead end. - Inject a loader via
DOWNLOAD MICROCODE, either as a firmware update or with the engineering-mode header. Rejected in protect mode: ABORT, error 4, status0x71. You cannot flash your way in while the drive is in this state.
Act three: the page that did it
FUN_60016c4c is the system-area mount routine. It walks blocks in ascending order on each CE, reads page 0, and classifies it by the first 32-bit word of the OOB:
value = *(u32 *)(0x50001200 + slot * 0x100);
if ((~value & 0xFFD00000) == 0) { /* ERROR_UNIT: a MASK test, not equality */
*(u8 *)0x5C0CEAAC = block; /* remember where */
*(u8 *)0x5C0CB5F0 |= 4; /* protect mode */
break;
}
if (value == 0xFFA00000) { /* SYSTEM_UNIT_HEADER */ }
if (value == 0x3FB10200) { /* BLOCK_MAP */ }
Two details in there matter more than they look.
The error-unit test is a mask, not a comparison. (~value & 0xFFD00000) == 0 means "every bit of 0xFFD00000 is present." 0xFFFFFFFF satisfies it too — so on a read path where an erased page presents as all-ones, an erased block would trip the check. On this drive erased pages read back as 0x00000000 through the VUC path, which is the only reason the obvious repair works at all. If you do this and the erased page comes back 0xffffffff, stop: you have just made the trigger permanent.
The scan does not abort on finding it. It sets the bit and keeps going, and returns success as long as it finds a header and a block map. That is why a drive can be in protect mode with a perfectly healthy FTL. Bit 2 alone is enough to lock you out of data that the controller can still address.
And one piece of arithmetic that cost me several days: firmware block numbers are physical blocks ÷ 4. When the firmware says "block 3", the block you have to erase is physical block 12. A firmware block is four internal units, and the OOB CE-id field confirms the grouping. Nothing says so anywhere; you find it by noticing that a RAM variable and your dump disagree by a factor of four.
What was actually in the error unit
Having finally found it — 53 written pages out of 512, one block on each of CE0 and CE1 — I dumped it. It is a crash log, and it is unusually talkative. The sub-type is tagged in the second OOB word:
| Tag | Pages | Contents |
|---|---|---|
0x111111 | 1 | Configuration record, starts 49 44 aa 00 ("ID") — NAND geometry and timings |
0x222222 | 2 | 101 records of 0x58 bytes: copies of the NAND command descriptor |
0x555555 | 36 | Zero-filled, reserved |
0x666666 | 14 | 176 KB of the firmware's own sec2, as it stood in SRAM |
The 101 descriptor records hold a fixed command (0x00440021), an index climbing from 0x1b to 0x7f, and two linear ramps with steps of 0x40 and 2. Fixed command, parameters stepping regularly, 101 times: that is a read-retry sweep. The firmware was retrying a read with shifted reference voltages. It gave up after 101 attempts, wrote a crash log, dumped its own code section into it, and left behind the page that would greet it at every subsequent boot.
The 0x666666 pages paid for themselves twice. First, they match my decrypted firmware image byte for byte, which validates the extraction — and, more usefully, settles a question. Commercial data-recovery vendors describe the PS3111-S11 as applying XOR scrambling to NAND writes. Whatever happens at the cell level, the VUC read path returns plaintext: here is 176 KB of known content, read back through that path, unscrambled. There is no XOR key to extract on this path. (Nearly everything findable about this controller's data format comes from one vendor's marketing pages, which contradict each other — a key derived from model and chip ID in one place, a two-layer "dynamic XOR" in another.)
Second, the variable region of those pages is a snapshot of controller state at the instant of the panic:
0x6005CD00 = 05 ff ff ff ff ff | 07 ff
System unit header at block 5, block map at block 7. The mount had succeeded when the failure hit. Whatever went wrong, it was not a corrupt system area.
The dead end I want to tell you about
Running in parallel with all this was my attempt to rebuild the volume from the raw dump. It produced the most instructive failure of the project, and it had nothing to do with the hardware.
A large fraction of frames, read as the 4 KB of their declared LBA, contained high-entropy data with a recurring 8-byte prefix. Obviously encrypted, then. So I went looking for the encoding. Everything in this table was measured:
| Hypothesis | Result |
|---|---|
| XOR with a key, aligned to known plaintext | XOR of plaintext and ciphertext has entropy 7.36 |
| Short repeating key (Vigenère) | Index of coincidence peaks at every multiple of 3 (L=3 → 0.0179 vs 0.0039 uniform), but the modal key per class (0ffcc0) yields neither zeros nor FILE |
| Bit reordering: even/odd bits, 2-bit pairs, 12-bit symbols, Gray decode, nibble swap, bit reversal | Entropy stays 7.17–7.32 in all six |
| Bit-level misalignment | No FILE at any of 8 shifts, either direction, nor bit-reversed |
| Already-compressed data | zlib still reduces it to 69%; an ordinary frame to 78% |
| Scrambling tables in the firmware | The recurring patterns appear nowhere in sec0/1/2 |
| Wrong read mode | OOB page index matches read order on every block tested, both modes |
| Uncorrectable ECC | Same distribution of corrected bits as ordinary pages |
I also built two "fingerprints" of the mystery format. Both were artefacts, and I am recording them so nobody reuses them:
- Bytes concentrated on 2-bit-aligned masks (
fc,c0,0f,03). Measured: 66.6% of adjacent bit pairs equal, 26.8% of bytes with all pairs equal — against 66.4% and 33.6% in an ordinary data frame. It is a property of binary data in general. - A dominant 3-byte period. An ordinary frame containing plain x86-64 code scores 0.189 at distance 3. A filter I built on that threshold duly selected x86 code: one of my "mystery candidates" began
48 89 54 24 30, which ismov [rsp+0x30], rdx.
Here is what was actually going on. Bit 0 of byte 8 of an OOB frame entry marks a partial frame — a frame that does not hold the full 4 KB of its declared LBA. Across 263 sampled versions, a frame with that bit set never held the complete 4 KB. Reading those frames as if they did means comparing unrelated bytes. There was no cipher. I was computing entropy on misalignment.
The hypothesis survived as long as it did because of how I was testing it. Each negative result — not XOR, not a reordering, not compressed — got read as "then it is a more complicated encoding" rather than as evidence against the premise. A chain of exclusions that narrows forever without ever questioning the set it started from.
What broke it was not a cleverer analysis. It was that the drive got fixed, and became an oracle I could query. I took 60 LBAs from the dump, read them from the repaired device, and compared them against every version of them in the dump:
| Outcome | n |
|---|---|
| A version matches byte for byte, at exactly the expected frame offset | 16 |
| The drive now reads all zeros (LBA deallocated; the dump holds stale data) | 18 |
| No version matches | 26 |
The 16 matches land at offset 0 with no shift, which proves the LBA decoding was right all along. Correlating OOB byte 8 against those three outcomes produced the partial-frame flag in an afternoon, and with it the explanation for every failed test above. The placement rule followed:
n = run length of the bits equal to bit 0 of byte 9, starting at bit 0
shift = 8 - n
frame[0 .. n*512) == device sectors [LBA + shift, LBA + 8)
Verified against the repaired drive by content hash: 60 of 60.
Every statistical dead end above was settled in a single afternoon, by having one known-good copy of the thing. That is the transferable lesson of this whole project, and it is embarrassingly simple: every high-value result came from checking an assumption against something already known — the read mode against the OOB page index, the ÷4 factor against a RAM variable, the partial-frame flag against the repaired drive. Every dead end came from looking for structure in statistics with no reference.
The repair
By this point the decision was fully determined by static analysis rather than by trial and error, which is the only reason I was willing to erase anything. One reason bit, both metadata blocks present, the error unit's location in a RAM variable, and the firmware's own classification rules reproduced in a script so I could see the page for myself.
Erasing a NAND block is irreversible, so the tool refuses unless three things hold: the target page carries the ERROR_UNIT magic read through the same addressing the erase will use, a local backup of the block exists and matches, and --yes is given. One block per CE — the scan reads them all, and one survivor re-sets the bit.
page 0 word0 = 0xffd00000 (ERROR_UNIT)
backup: ... word0 matches
ERASE done
after: word0 = 0x00000000 (LBA 0x0), OOB byte8 = 0xff
Then the soft reset, so init runs again. An S3 suspend is not enough — that is a COMRESET, and the scan runs on controller reset:
before: flag=04, IDENTIFY model='SATAFIRM S11'
0x5c0cb5f0 [1] 00 flag = 0x00: no bits set
0x5c0ceaac [1] ff error unit block -> NONE
after: IDENTIFY model='SPCC M.2 SSD' firmware='SBFM21.1'
The line that matters is 0x5c0ceaac = ff. The flag is zero because init re-ran and found nothing wrong, not because I wrote a zero into it. After a SCSI rescan the volume was there: GPT intact, NTFS boot sector valid, MFT starting with 46 49 4c 45 — FILE. 41.5 GB of live data on a 119.3 GB volume, all of it readable.
I imaged it immediately, over USB rather than the network (126 MB/s against 11), and booted the image in QEMU behind a qcow2 overlay with UEFI firmware, with -nic none — a recovered Windows install may carry remote-access software that starts on its own and would appear online with the same identity as the real machine. It reached the desktop.
The actual cause, which was not the drive
Once the drive was healthy, smartctl -x worked fine, and it had something to say:
| Attribute | Value | |
|---|---|---|
| Power_On_Hours | 74999 | 8.6 years |
| Power_Cycle_Count | 2368 | |
| Power-Off_Retract_Count | 2358 | unexpected power loss |
| (life left) | 92 | zero read errors |
2358 unclean shutdowns out of 2368 power cycles. 99.6% of this drive's life ended in sudden power loss. It had 92% of its write endurance left. It was not worn out; it was being switched off.
Put that next to the rest of the evidence — a metadata block full and needing roll-over, one of the four firmware copies carrying an erased page in the middle, and a crash log written during a read-retry sweep — and the picture is consistent: an interrupted system-area update.
Which means the repair is not the fix. If your drive looks like this, erasing a block will get your data back, and then the same thing will happen again. The fix is a clean shutdown path, or a UPS.
What I did not solve
The recovery is finished — the drive works, the image boots — so none of this blocked anything. But the raw-dump reconstruction path is genuinely incomplete, and I would rather say so plainly than let someone discover it the hard way:
- Version ordering. Sorting versions of the same LBA by
(superblock sequence, physical page index)picks the correct one 42% of the time among complete frames. The key is wrong and I do not know the right one. - Coverage. The dump held 77% of the volume's live clusters, but only 50% in complete frames. An NTFS volume missing a quarter of its used clusters does not mount. Repairing the controller and letting its own firmware serve the data was the shorter path by a very wide margin.
- 23% of live clusters were in no dumped page at all, after searching all 8768 block files for their content. I recorded this as unexplained when the dump was my only evidence. It no longer is — the repaired drive can be queried directly — and I simply never went back to it.
- Bit 3 of the protect flag. Read together with bit 2, no setter located anywhere in the firmware. Which means a drive can be held in protect mode by a bit whose cause nobody can currently name.
A note on how this was done
I worked through this with an LLM — Claude — as a partner the whole way: reading Xtensa decompilation, proposing and discarding hypotheses, writing the tools, and writing this page. I am saying so because the division of labour turned out to be more interesting than the fact, and because you are reading prose it helped produce.
What it was unambiguously good at was volume. Scanning a few thousand lines of decompiled Xtensa for the one store to one address is mechanical and tedious, and it does not get bored or skip ahead. Same for cross-checking an OOB field across 8768 block files, or grinding through all eight hypotheses in the table above and reporting each negative result cleanly.
What it was bad at is the part that cost the most time, and the shape of it was characteristic. That chain of exclusions above is largely a shared failure. Each negative result came back as a more elaborate hypothesis — stated, I should add, with noticeably more confidence than the evidence supported — rather than as a reason to doubt the premise. A partner willing to generate the next hypothesis indefinitely makes it very easy never to stop and ask whether the question itself is wrong. Meanwhile the ÷4 block factor and the pSLC read mode, which were the two things actually blocking everything, were both found the ordinary way: by noticing that two numbers that should have agreed did not. Both had been sitting in plain view for days.
So the honest summary is that the deadlock was not broken by a better reasoner. It was broken by ground truth — a repaired drive I could query — and the rule fell out in an afternoon. I think that is worth holding onto at the current moment of enthusiasm. The binding constraint on this project was never the rate at which hypotheses could be produced. It was the rate at which they could be checked against something already known, and nothing about an LLM improves that rate. It will, however, happily help you generate wrong answers faster than you can check them.
If you have one of these drives
Two read-only commands tell you whether your drive is this case. Neither writes anything.
# 1. the symptom
sudo hdparm -I /dev/sdX | grep -E 'Model|Firmware'
# 2. the cause, straight out of controller RAM
sudo PYTHONPATH=/path/to/psychite/src \
python3 tools/safe_dump.py /dev/sgX diag-protect
Four things have to hold before the repair in this writeup applies: the flag reads exactly 04, the error-unit block is not ff, and the system unit header and block map were both found.
If any other bit is set, or the header and map come back ff, stop. Your cause is something else, and erasing would be a guess. And dump the whole chip, in the correct read mode, before you write anything at all.
One more thing, in case it saves someone a bad afternoon: this drive hung the host during boot until I passed systemd.mask=smartmontools.service and ahci.mobile_lpm_policy=1. smartd querying it during startup consistently preceded the hard-reset failure. After the repair, smartctl worked without trouble — which suggests the fragility belonged to protect mode and not to SMART, though I never proved it.
Everything is in the repo
The notes, the memory map, the OOB format, the dead ends with their measurements, a machine-readable JSON summary of every address and magic value, the Ghidra scripts that load and label the firmware, and the tools — including an allowlist wrapper over the vendor command set, so that a typo cannot reach an erase or a firmware write:
github.com/stperin/phison-ps3111-s11-recovery
It is MIT licensed. Psychite is not vendored there — it declares no licence, so it is not redistributed; install it from upstream.
This is work I did for myself, on my own drive, and it worked: I got the data back. It is not a polished product and does not pretend to be — one drive, one fault, with the dead ends left in. I am publishing it because the findings should be out there rather than sitting in my notes, and because someone facing a different Phison problem deserves somewhere to start. Take whatever is useful.