""" XX宝小程序缓存 .dc 文件通用提取工具 ========================================
格式逆向结果 (0.dc):
0.dc 是一个"外层 tar 容器", 内部条目: .signature 342B 签名/哈希 (base64) .meta 14B 元数据 .manifest 887B protobuf 文件索引 每条目: 0A <文件名> 12 { 08 <序号> 10 <流内偏移> 18 <大小> } 30 <CRC32> <appid>.tar ~6MB 嵌套小程序包 (appId 如 2021001190xxxxxx)
嵌套小程序包 <appid>.tar 内部是标准 ustar tar 流 (21 个文件): meta.json appConfig.json api_permission tabBar.json manifest.json index.worker.js index.js splitchunk~N/index.js resources/images/...
两种提取方式: A) 直接解析 ustar tar 流 (自包含, 文件名/大小在 tar 头里) B) 按 .manifest 的 protobuf 索引偏移提取, 并用 CRC32(v6) 校验
用法: python dc_extractor.py <0.dc> [-o 输出目录] 无参数进入交互模式 """ import argparse import os import sys import zlib
BLOCK = 512 TAR_MAGIC = b'ustar\x00'
def varint(buf, pos): result = 0 shift = 0 while True: b = buf[pos] pos += 1 result |= (b & 0x7F) << shift if not (b & 0x80): break shift += 7 return result, pos
def parse_msg(buf, start, end): fields = {} pos = start while pos < end: t = buf[pos] pos += 1 fnum = t >> 3 wtype = t & 7 if wtype == 0: v, pos = varint(buf, pos) fields.setdefault(fnum, []).append(('v', v)) elif wtype == 2: ln, pos = varint(buf, pos) fields.setdefault(fnum, []).append(('b', buf[pos:pos + ln])) pos += ln else: break return fields
def find_tar_start(data): """扫描 512 对齐位置, 定位 ustar tar 流起点""" for pos in range(0, len(data) - BLOCK, BLOCK): if data[pos + 257:pos + 263] == TAR_MAGIC: size_raw = data[pos + 124:pos + 136].rstrip(b'\x00').rstrip(b' ') if size_raw.isdigit() and len(size_raw) <= 11: return pos return None
def parse_tar_stream(data, start): """解析 tar 流, 返回 [(name, size, content_off, mtime, typeflag, valid)]""" entries = [] pos = start n = len(data) while pos + BLOCK <= n: block = data[pos:pos + BLOCK] if block == b'\x00' * BLOCK: break if block[156] == ord('L'): ln = int(block[124:136].rstrip(b'\x00').rstrip(b' ') or b'0', 8) longname = data[pos + BLOCK:pos + BLOCK + ln].split(b'\x00')[0].decode('utf-8', 'replace') pos += BLOCK + ((ln + BLOCK - 1) // BLOCK) * BLOCK block = data[pos:pos + BLOCK] name = longname else: name = block[0:100].split(b'\x00')[0].decode('utf-8', 'replace') size_raw = block[124:136].rstrip(b'\x00').rstrip(b' ') try: size = int(size_raw or b'0', 8) except ValueError: break mtime = int((block[136:148].rstrip(b'\x00').rstrip(b' ') or b'0'), 8) typeflag = chr(block[156]) if block[156] else '0' coff = pos + BLOCK entries.append((name, size, coff, mtime, typeflag, coff + size <= n)) pos = coff + ((size + BLOCK - 1) // BLOCK) * BLOCK return entries
def parse_manifest_index(manifest_bytes): """解析 .manifest 内容为索引: [(name, id, offset, size, crc32)]""" rows = [] pos = 0 n = len(manifest_bytes) while pos + 2 < n: if manifest_bytes[pos] != 0x1A: pos += 1 continue ln, q = varint(manifest_bytes, pos + 1) end = q + ln if end > n: break f = parse_msg(manifest_bytes, q, end) if 1 in f and 2 in f: try: name = f[1][0][1].decode('utf-8', 'replace') except Exception: name = '<bin>' sub = parse_msg(f[2][0][1], 0, len(f[2][0][1])) idv = sub.get(1, [('v', 0)])[0][1] v2 = sub.get(2, [('v', 0)])[0][1] v3 = sub.get(3, [('v', 0)])[0][1] v6 = f.get(6, [('v', None)])[0][1] rows.append((name, idv, v2, v3, v6)) pos = end return rows
def extract_tar_entries(data, start, out_dir, crc_map=None, depth=0): """解析并写出 tar 流, 返回 (ok_count, bad_list)""" entries = parse_tar_stream(data, start) if not entries: return 0, []
pad = ' ' * depth print('%s%s[tar 流起点 0x%X, %d 个条目]' % (pad, '├─ ' if depth else '', start, len(entries))) ok = bad = 0 bad_list = [] nested = [] for name, size, coff, mtime, typeflag, valid in entries: rel = name.lstrip('/') if typeflag == '5': os.makedirs(os.path.join(out_dir, rel), exist_ok=True) continue if rel.endswith('/'): continue content = data[coff:coff + size] if valid else b'' target = os.path.join(out_dir, rel) os.makedirs(os.path.dirname(target), exist_ok=True) if os.path.dirname(target) else None with open(target, 'wb') as fp: fp.write(content)
status = '' if crc_map and name in crc_map: if zlib.crc32(content) == crc_map[name]: status = 'OK'; ok += 1 else: status = 'CRC MISMATCH!'; bad += 1; bad_list.append(name) print('%s %-36s %10d %s' % (pad, name, size, status))
if content[:512].find(TAR_MAGIC) != -1 and (rel.endswith('.tar') or rel.endswith('.pkg2') or rel.endswith('.tbp')): nested.append((rel, content)) return ok, bad, nested
def extract(dc_path, out_dir=None): data = open(dc_path, 'rb').read() start = find_tar_start(data) if start is None: print('[错误] 未找到 ustar tar 流, 不是有效的 .dc 缓存文件') return 1
if not out_dir: base = os.path.splitext(os.path.basename(dc_path))[0] or 'dc' out_dir = base + '_extracted' os.makedirs(out_dir, exist_ok=True)
print('文件: %s (%d 字节)' % (dc_path, len(data))) print('外层 tar 流起点: 0x%X' % start) print()
entries = parse_tar_stream(data, start) crc_map = {} nested = [] for name, size, coff, mtime, typeflag, valid in entries: rel = name.lstrip('/') if typeflag == '5' or rel.endswith('/'): continue content = data[coff:coff + size] if valid else b'' target = os.path.join(out_dir, rel) os.makedirs(os.path.dirname(target), exist_ok=True) if os.path.dirname(target) else None with open(target, 'wb') as fp: fp.write(content) if rel == '.manifest': crc_map = {r[0]: r[4] for r in parse_manifest_index(content) if r[4] is not None} print('解析 .manifest: %d 个文件索引 (含 CRC32)' % len(crc_map)) if content[:512].find(TAR_MAGIC) != -1 and rel.endswith('.tar'): nested.append((rel, content))
print() print('外层 tar 条目:') for name, size, coff, mtime, typeflag, valid in entries: print(' %-36s %10d' % (name, size)) print()
for rel, content in nested: print('检测到嵌套小程序包: %s (%d 字节)' % (rel, len(content))) sub_dir = os.path.join(out_dir, rel + '_extracted') os.makedirs(sub_dir, exist_ok=True) ns = find_tar_start(content) if ns is not None: print() ok, bad, _ = extract_tar_entries(content, ns, sub_dir, crc_map=crc_map, depth=1) print(' [嵌套包校验] CRC32 通过 %d 个, 异常 %d 个' % (ok, bad)) else: print(' [警告] 嵌套包内未找到 tar 流') print() print('完成: 所有文件已提取到 %s' % out_dir) return 0
def interactive(): print('=' * 56) print(' 支付宝小程序缓存 .dc 文件通用提取工具') print(' 格式: 外层tar容器 → 嵌套appid.tar → ustar文件流') print('=' * 56) while True: print() path = input(' 输入 .dc 文件路径 (回车退出): ').strip().strip('"') if not path: break if not os.path.isfile(path): print(' [错误] 文件不存在') continue out = input(' 输出目录 (直接回车用默认): ').strip().strip('"') extract(path, out or None) print('已退出')
def main(): if sys.stdout.encoding and sys.stdout.encoding.lower() not in ('utf-8', 'utf8'): try: sys.stdout.reconfigure(encoding='utf-8', errors='replace') sys.stderr.reconfigure(encoding='utf-8', errors='replace') except Exception: pass parser = argparse.ArgumentParser(description='支付宝 .dc 缓存文件通用提取工具') parser.add_argument('dc_file', nargs='?', help='.dc 文件路径') parser.add_argument('-o', '--outdir', help='输出目录 (默认 <文件名>_extracted)') args = parser.parse_args() if not args.dc_file: interactive() else: sys.exit(extract(args.dc_file, args.outdir))
if __name__ == '__main__': main()
|