Files
ext4recovery/test/nbd_v2.py
2026-04-30 11:04:05 +00:00

352 lines
13 KiB
Python

#!/usr/bin/env python3
"""
NBD server for PERC H710 RAID recovery.
Applies three transformations on every read:
1. Chunk translation — skips every 5th 64KB chunk (PERC internal metadata)
2. Superblock patch — clears metadata_csum and has_journal feature bits
so the kernel stops validating checksums we can't fix
3. GDT reconstruction — synthesizes correct group descriptor entries for
regions that fall inside metadata chunks (zeros)
Confirmed filesystem parameters (from session forensics):
bpg = 32768 blocks per group
ipg = 8192 inodes per group
inode_size = 256 bytes
GDT entry = 64 bytes (64bit feature)
num_groups = 35728
Group N: bb = 1038+N, ib = 1054+N, it = 1070 + N*512
Usage:
python3 nbd_server_v2.py &
nbd-client -g 127.0.0.1 10809 /dev/nbd0
mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root
"""
import socket
import struct
import threading
import os
import sys
# ── Physical layout ───────────────────────────────────────────────────────────
DEV = '/dev/md0'
CHUNK_BYTES = 128 * 512 # 64 KB
LV_PHYS_START = 5120000 * 512 # byte 2,621,440,000
VIRT_SIZE = 9365766144 * 512 # from superblock block count
# ── ext4 filesystem parameters ────────────────────────────────────────────────
BSIZE = 4096 # block size in bytes
BPG = 32768 # blocks per group
IPG = 8192 # inodes per group
INODE_SIZE = 256 # inode size in bytes
GDT_ENTRY = 64 # group descriptor size (64-bit mode)
NUM_GROUPS = 35728 # total number of block groups
# GDT starts at block 1 = byte 4096 from LV start
GDT_START_VIRT = BSIZE # virtual byte offset of GDT
GDT_END_VIRT = BSIZE + NUM_GROUPS * GDT_ENTRY # ~9.1 MB
# Superblock is at virtual byte 1024
SB_VIRT_OFFSET = 1024
SB_SIZE = 1024
# Feature flag offsets within superblock
SB_FEAT_COMPAT_OFF = 92 # s_feature_compat
SB_FEAT_INCOMPAT_OFF = 96 # s_feature_incompat
SB_FEAT_RO_COMPAT_OFF = 100 # s_feature_ro_compat
SB_CHECKSUM_OFF = 1020 # s_checksum (last 4 bytes of sb)
# Bits to clear
INCOMPAT_HAS_JOURNAL = 0x00000004
RO_COMPAT_METADATA_CSUM = 0x00000400
RO_COMPAT_GDT_CSUM = 0x00000010
# Cached patched superblock (built once on first read)
_patched_sb = None
_sb_lock = threading.Lock()
# ── NBD protocol constants ────────────────────────────────────────────────────
NBD_MAGIC = 0x4e42444d41474943
NBD_CLISERV_MAGIC = 0x00420281861253
NBD_REQUEST_MAGIC = 0x25609513
NBD_REPLY_MAGIC = 0x67446698
NBD_CMD_READ = 0
NBD_CMD_WRITE = 1
NBD_CMD_DISC = 2
NBD_FLAG_READ_ONLY = 0x0002
NBD_FLAG_HAS_FLAGS = 0x0001
# ── Chunk translation ─────────────────────────────────────────────────────────
def virt_to_phys_segments(virt_offset, length):
"""
Yield (phys_offset_or_None, seg_length) pairs covering the request.
None means the region falls in a PERC metadata chunk → return zeros.
"""
remaining = length
pos = virt_offset
while remaining > 0:
group = pos // (5 * CHUNK_BYTES)
in_group = pos % (5 * CHUNK_BYTES)
chunk_idx = in_group // CHUNK_BYTES
intra = in_group % CHUNK_BYTES
chunk_remain = CHUNK_BYTES - intra
seg_len = min(chunk_remain, remaining)
if chunk_idx == 4:
yield None, seg_len
else:
phys = (LV_PHYS_START
+ group * 4 * CHUNK_BYTES
+ chunk_idx * CHUNK_BYTES
+ intra)
yield phys, seg_len
pos += seg_len
remaining -= seg_len
def raw_read(virt_offset, length):
"""Read virtual bytes without any patching."""
result = bytearray(length)
written = 0
with open(DEV, 'rb') as f:
for phys, seg_len in virt_to_phys_segments(virt_offset, length):
if phys is not None:
f.seek(phys)
data = f.read(seg_len)
result[written:written + len(data)] = data
written += seg_len
else:
written += seg_len # leave as zeros
return bytes(result)
# ── Group descriptor synthesis ────────────────────────────────────────────────
def make_gdt_entry(group_num):
"""
Build a 64-byte group descriptor for group N.
Pattern confirmed from session forensics:
block_bitmap_lo = 1038 + N
inode_bitmap_lo = 1054 + N
inode_table_lo = 1070 + N * 512
We set free counts to 0 and used_dirs to 0 — the kernel will recompute
them if needed. Checksum is left zero (we cleared metadata_csum).
"""
gd = bytearray(GDT_ENTRY)
bb = 1038 + group_num
ib = 1054 + group_num
it = 1070 + group_num * 512
struct.pack_into('<I', gd, 0, bb) # bg_block_bitmap_lo
struct.pack_into('<I', gd, 4, ib) # bg_inode_bitmap_lo
struct.pack_into('<I', gd, 8, it) # bg_inode_table_lo
struct.pack_into('<H', gd, 12, 0) # bg_free_blocks_count_lo
struct.pack_into('<H', gd, 14, 0) # bg_free_inodes_count_lo
struct.pack_into('<H', gd, 16, 0) # bg_used_dirs_count_lo
struct.pack_into('<H', gd, 18, 0) # bg_flags
struct.pack_into('<H', gd, 30, 0) # bg_checksum (cleared)
return bytes(gd)
def synthesize_gdt_region(virt_offset, length):
"""
Return synthesized GDT bytes for the virtual address range
[virt_offset, virt_offset+length) which overlaps the GDT area.
"""
result = bytearray(length)
for i in range(length):
abs_byte = virt_offset + i
if abs_byte < GDT_START_VIRT or abs_byte >= GDT_END_VIRT:
continue
gdt_rel = abs_byte - GDT_START_VIRT
grp = gdt_rel // GDT_ENTRY
byte_in = gdt_rel % GDT_ENTRY
if grp < NUM_GROUPS:
entry = make_gdt_entry(grp)
result[i] = entry[byte_in]
return bytes(result)
def overlaps(a_start, a_len, b_start, b_len):
return a_start < b_start + b_len and b_start < a_start + a_len
# ── Superblock patching ───────────────────────────────────────────────────────
def get_patched_superblock():
"""
Read the real superblock once, clear problematic feature bits, cache it.
Clears:
- has_journal (skip journal replay)
- metadata_csum (stop checksum validation of bitmaps/GDT)
- gdt_csum (older checksum variant)
"""
global _patched_sb
with _sb_lock:
if _patched_sb is not None:
return _patched_sb
sb = bytearray(raw_read(SB_VIRT_OFFSET, SB_SIZE))
incompat = struct.unpack_from('<I', sb, SB_FEAT_INCOMPAT_OFF)[0]
ro_compat = struct.unpack_from('<I', sb, SB_FEAT_RO_COMPAT_OFF)[0]
incompat &= ~INCOMPAT_HAS_JOURNAL
ro_compat &= ~RO_COMPAT_METADATA_CSUM
ro_compat &= ~RO_COMPAT_GDT_CSUM
struct.pack_into('<I', sb, SB_FEAT_INCOMPAT_OFF, incompat)
struct.pack_into('<I', sb, SB_FEAT_RO_COMPAT_OFF, ro_compat)
# Zero the superblock checksum (last 4 bytes) — no longer valid
struct.pack_into('<I', sb, SB_CHECKSUM_OFF, 0)
_patched_sb = bytes(sb)
print(f'[sb] patched incompat=0x{incompat:08x} ro_compat=0x{ro_compat:08x}')
return _patched_sb
# ── Main read function ────────────────────────────────────────────────────────
def read_virtual(virt_offset, length):
"""
Read `length` bytes from virtual offset `virt_offset`, applying:
1. Chunk translation (skip PERC metadata chunks)
2. Superblock patching
3. GDT synthesis for metadata-chunk regions within the GDT
"""
# Start with raw translated data
data = bytearray(raw_read(virt_offset, length))
req_end = virt_offset + length
# ── Patch 1: superblock ───────────────────────────────────────────────────
sb_start = SB_VIRT_OFFSET
sb_end = SB_VIRT_OFFSET + SB_SIZE
if overlaps(virt_offset, length, sb_start, SB_SIZE):
patched = get_patched_superblock()
copy_start = max(virt_offset, sb_start)
copy_end = min(req_end, sb_end)
src_off = copy_start - sb_start
dst_off = copy_start - virt_offset
n = copy_end - copy_start
data[dst_off:dst_off + n] = patched[src_off:src_off + n]
# ── Patch 2: GDT reconstruction ──────────────────────────────────────────
# Only needed for regions that overlap the GDT and fall in metadata chunks
if overlaps(virt_offset, length, GDT_START_VIRT, GDT_END_VIRT - GDT_START_VIRT):
# Walk the metadata chunks within this request to find zero regions
# inside the GDT and replace with synthesized data
pos = virt_offset
remaining = length
while remaining > 0:
group = pos // (5 * CHUNK_BYTES)
in_group = pos % (5 * CHUNK_BYTES)
chunk_idx = in_group // CHUNK_BYTES
intra = in_group % CHUNK_BYTES
seg_len = min(CHUNK_BYTES - intra, remaining)
if chunk_idx == 4:
# This is a metadata chunk — was read as zeros
# If it overlaps the GDT, synthesize
seg_end = pos + seg_len
if overlaps(pos, seg_len, GDT_START_VIRT,
GDT_END_VIRT - GDT_START_VIRT):
synth = synthesize_gdt_region(pos, seg_len)
dst_off = pos - virt_offset
data[dst_off:dst_off + seg_len] = synth
pos += seg_len
remaining -= seg_len
return bytes(data)
# ── NBD protocol (old-style handshake) ───────────────────────────────────────
def handle_client(conn, addr):
print(f'[nbd] client connected from {addr}')
try:
# Old-style handshake: magic + cliserv_magic + size + flags + 124 pad
conn.sendall(b'NBDMAGIC')
conn.sendall(struct.pack('>Q', NBD_CLISERV_MAGIC))
conn.sendall(struct.pack('>Q', VIRT_SIZE))
conn.sendall(struct.pack('>H', NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY))
conn.sendall(b'\x00' * 124)
print(f'[nbd] handshake done, serving {VIRT_SIZE // (1024**3):.1f} GB')
while True:
hdr = b''
while len(hdr) < 28:
chunk = conn.recv(28 - len(hdr))
if not chunk:
return
hdr += chunk
magic, flags, cmd, handle, offset, length = \
struct.unpack('>IHHQQI', hdr)
if magic != NBD_REQUEST_MAGIC:
print(f'[nbd] bad magic 0x{magic:08x}, closing')
return
if cmd == NBD_CMD_READ:
try:
payload = read_virtual(offset, length)
except Exception as e:
print(f'[nbd] read error offset={offset} len={length}: {e}')
payload = b'\x00' * length
reply = struct.pack('>IIQ', NBD_REPLY_MAGIC, 0, handle)
conn.sendall(reply + payload)
elif cmd == NBD_CMD_DISC:
print(f'[nbd] client disconnected cleanly')
return
else:
# Return error for writes and other commands
reply = struct.pack('>IIQ', NBD_REPLY_MAGIC, 1, handle)
conn.sendall(reply)
except (ConnectionResetError, BrokenPipeError):
print(f'[nbd] client {addr} dropped connection')
except Exception as e:
print(f'[nbd] error: {e}')
finally:
conn.close()
def main():
print(f'[nbd] PERC H710 recovery NBD server v2')
print(f'[nbd] device: {DEV}')
print(f'[nbd] lv_start: byte {LV_PHYS_START} (sector {LV_PHYS_START//512})')
print(f'[nbd] virt_size: {VIRT_SIZE // (1024**3):.1f} GB')
print(f'[nbd] features: chunk-skip + sb-patch + gdt-synth')
print()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 10809))
server.listen(5)
print(f'[nbd] listening on 127.0.0.1:10809')
print(f'[nbd] connect with: nbd-client -g 127.0.0.1 10809 /dev/nbd0')
print(f'[nbd] then mount: mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root')
print()
while True:
conn, addr = server.accept()
t = threading.Thread(target=handle_client, args=(conn, addr),
daemon=True)
t.start()
if __name__ == '__main__':
main()