#!/usr/bin/env python3 """ Strict ext4 directory entry scanner for pterodactyl paths. """ import struct CHUNK = 128 * 512 LV_START = 5120000 * 512 BSIZE = 4096 DISKS = ['/dev/sda', '/dev/sdd', '/dev/sdc', '/dev/sdb'] # Only exact target names we expect as directory entries EXACT_TARGETS = [ b'pterodactyl', b'volumes', b'wings', ] def is_valid_dirent(block, off, name): """Strict validation of an ext4 directory entry.""" if off + 8 + len(name) > BSIZE: return False inode = struct.unpack_from(' 10, not absurdly large) if not (10 < inode < 500_000_000): return False # name_len must exactly match our target if name_len != len(name): return False # rec_len must be >= 8 + name_len and <= 4096 # and aligned to 4 bytes min_rec = 8 + name_len if rec_len < min_rec or rec_len > BSIZE or rec_len % 4 != 0: return False # file type must be a known ext4 type if ftype not in (1, 2, 7): # file, dir, symlink only return False # the name bytes must match exactly and be clean ASCII actual_name = block[off+8:off+8+name_len] if actual_name != name: return False # byte immediately after name (padding) should be 0 pad_off = off + 8 + name_len if pad_off < BSIZE and block[pad_off] != 0: return False # Previous entry should also look valid if we're not at start of block # (skip this check for now - too complex) return True def scan_block(block, phys_base): hits = [] for off in range(0, BSIZE - 8): for target in EXACT_TARGETS: # Quick check: does target appear at this offset+8? if block[off+8:off+8+len(target)] != target: continue if is_valid_dirent(block, off, target): inode = struct.unpack_from('= 13, 'rec_len': rec_len, }) return hits def iter_data_chunks(disk_path): with open(disk_path, 'rb') as f: f.seek(0, 2) disk_size = f.tell() chunk_num = 0 with open(disk_path, 'rb') as f: phys = LV_START while phys + CHUNK <= disk_size: if chunk_num % 5 != 4: f.seek(phys) yield phys, f.read(CHUNK) phys += CHUNK chunk_num += 1 def main(): all_hits = [] for disk_idx, disk in enumerate(DISKS): print(f'\nScanning {disk}...', flush=True) chunks = 0 hits = 0 for phys, chunk_data in iter_data_chunks(disk): # Pre-filter: any target in chunk? if not any(t in chunk_data for t in EXACT_TARGETS): chunks += 1 continue # Scan each 4KB block in chunk for blk in range(0, len(chunk_data), BSIZE): block = chunk_data[blk:blk+BSIZE] for hit in scan_block(block, phys + blk): status = 'INTACT' if hit['intact'] else 'LOST' print(f" [{status}] '{hit['name']}' " f"inode={hit['inode']} " f"group={hit['group']} " f"type={hit['ftype']} " f"phys={hit['phys']}") all_hits.append((disk_idx, hit)) hits += 1 chunks += 1 if chunks % 5000 == 0: gb = (phys - LV_START) / 1024**3 print(f' {disk}: {gb:.1f}GB, {hits} hits', flush=True) print(f' Finished: {hits} hits') print('\n=== RESULTS ===') # Group by name and inode from collections import defaultdict by_inode = defaultdict(list) for disk_idx, hit in all_hits: key = (hit['inode'], hit['name']) by_inode[key].append((DISKS[disk_idx], hit['phys'])) print(f'\nUnique (inode, name) pairs: {len(by_inode)}') for (inode, name), locations in sorted(by_inode.items()): grp = (inode-1)//8192 status = 'INTACT' if grp >= 13 else 'LOST' print(f" '{name}' inode={inode} group={grp} [{status}]") for disk, phys in locations[:3]: print(f" {disk} phys={phys}") if __name__ == '__main__': main()