352 lines
13 KiB
Python
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()
|