朕喜技术(ZhenxiJS)
2026.09.21
1 分钟阅读

tar 报 Skipping to next header 但 gzip -t 通过、过滤器退出码 0:归档脱敏过滤器静默损坏 tar 流(头区定长字段被整块替换 + 未重算 checksum + 流式状态跨成员污染 + 结尾零块按输入长度补)

日期:2026-09-21 环境:Debian bookworm / GNU tar 1.34 / Python 3.11.2 / Docker Compose 技术栈与关键词:tar、Skipping to next header、unexpected EOF、tar 头区(tar header)、header checksum、ustar、GNUTYPE、定长字段、512 字节块、blocking factor、--transform、流式过滤器、tar 归档脱敏、静默数据损坏、静默截断、退出码 0、gzip -t、tar -tf、tar -xf、二进制结构上做文本替换


一、问题背景

公司有一条「归档脱敏」流水线。运维把本地曲库/备份/日志打包上传外部存储, 但归档里不能出现敏感路径和敏感文件名(/srv/data、带 secret 的文件、.gz 压缩包)。 方案是一段标准的「tar 流过滤器」,挂在 GNU tar 文档说的那个位置:

tar -cf - --transform='s#^srv/data#srv/<PATH>#' ... . | ./tarfilter.py | gzip > out.tgz

tarfilter.py 做三件事:扫流替换敏感值、大成员走流式分支、收尾自检。

业务反馈:外部存储上收到的归档,一大半解不开。

$ tar -tzf out.tgz
./music/
./music/曲库/
./music/曲库/日本語の歌.mp3
./music/track01.mp3
./backup/
tar: Skipping to next header
tar: Exiting with failure status due to previous errors

但运维这边每一项看起来都是好的:

  • 过滤器退出码 0,日志 in=30720 out=13824 members=16 streamed=1 一行不落,没有 ERROR;
  • gzip -t out.tgz 通过(压缩层没坏);
  • 归档不是空的,有几万字节,前面的成员也确实能列出来;
  • 更迷惑的是:前面的成员一切正常,坏的地方总在中间偏后,而且每次坏的位置还不完全一样。

于是被误判成「外部存储传坏了」或「gzip 版本不兼容」。

一个关键观察

把过滤器摘掉、原样打一份归档,tar -tzf 完全正常 —— 坏就坏在过滤器这一段, 而在过滤器自己看来「没有发生任何异常」。


二、复现步骤

git fetch origin
git checkout 2026-09-21-0000
cd scenario/2026-09-21-0000
docker compose up -d --build

docker compose exec app bash ops/verify.sh   # 问题态 PASS=12 FAIL=6
docker compose exec app bash ops/accept.sh   # 业务侧 RESULT: BAD

手工复现那三个「不合常理」的现场:

docker compose exec app bash -c '
  W=$(mktemp -d) && sh fixtures/make_fixtures.sh $W >/dev/null
  python3 app/tarfilter.py < $W/archive.tar > $W/out.tar
  echo "原始成员: $(tar -tf $W/archive.tar | wc -l)"   # 16
  echo "过滤成员: $(tar -tf $W/out.tar | wc -l)"       # 5
  echo "tar exit=$?"
  ls -l $W/archive.tar $W/out.tar
'
# [tarfilter] in=30720 out=13824 members=16 streamed=1     ← 日志全绿
# 原始成员: 16 / 过滤成员: 5
# 30720 字节 -> 13824 字节                                  ← 输出只有输入的一半
# tar exit=0                                                ← exit code 竟然是 0

最小复现:一个成员就够(说明问题不在成员数量):

docker compose exec app bash -c '
  mkdir -p /tmp/min/notes
  head -c 1000 /dev/urandom | base64 > /tmp/min/notes/user_secret.log
  cd /tmp/min && tar --format=gnu -cf s.tar notes
  python3 /scenario/app/tarfilter.py < s.tar > o.tar
  python3 - <<PY
s=open("/tmp/min/s.tar","rb").read(); o=open("/tmp/min/o.tar","rb").read()
for lab,d in (("原始",s),("过滤后",o)):
    b=d[512:1024]
    print(lab,"name  =",repr(b[0:60]))
    print(lab,"size  =",repr(b[124:136])," mtime=",repr(b[136:148])," chksum=",repr(b[148:156]))
print("字节数:", len(s), "->", len(o))
PY
'

实测输出 —— 物证就位:

原始 name  = b'notes/user_secret.log\x00\x00...'
原始 size  = b'00000002512\x00'  mtime = b'15254002473\x00'  chksum=b'013740\x00 '
过滤后 name  = b'notes/<SECRETNAME>\x00\x00...'
过滤后 size  = b'00000002512\x00'  mtime = b'54002473\x00013'  chksum=b'740\x00 0\x00\x00'
                ^^^^^^^^^^^^^^^            ^^^^^^^^^^^^^^^         ^^^^^^^^^^^^^^^
                还是原始值                  被吃掉 3 位             完全不是校验和了
字节数: 10240 -> 10237      ← 整块 512 变 509,这就是根因

mtime 里的 54002473\x00 013 是原 mtime 和原 chksum 被顶左后残留的字节 —— 典型的「定长二进制结构被文本替换顶位」现场。


三、根本原因

四处缺陷互相掩护,只修一处判定依然红。

缺陷① 流式 / 跳过两个状态跨成员污染

streaming = False          # 循环外只初始化一次
skipping = False
while ...:
    if hit: skipping = True            # 置位后不复位
    if skipping: pos += advance; continue
    if size > threshold: streaming = True   # 置位后不复位

后果:第 6 个成员 ./music/skipme.tmp 命中 EXCLUDE 之后, 后面 10 个成员全被 continue 掉,一个字节都没写出去 —— 这解释了「输出只有输入一半」和「tar -tf 只列出 5 个成员」。

指纹速判:输出长度 ≈ 第一个被跳过成员之前的长度,就是这个 bug。

缺陷② 整块 512 字节过正则 + 不重算 checksum

new_block = sanitize(block, rules)     # 把整个头区当文本过正则

tar 头区是定长定偏移结构:

偏移    长度   字段
0       100    name(NUL 结尾)
100       8    mode
108       8    uid
116       8    gid
124      12    size(八进制)
136      12    mtime
148       8    chksum  ← 校验和
156       1    typeflag
257       6    magic("ustar")
345      12    prefix

/srv/data(8) → <PATH>(6) 这类替换会让名字变短, 正则替换后整块从 512 变成 509 字节,后面所有字段整体左移。 磁盘上仍然是「512 字节的块」,但 size 字段已经指向别的字节 —— tar 读到的 size 是乱码,于是按错的偏移找下一个头 → Skipping to next header。

而且改了 name 必须重算 chksum:长度对、偏移对、唯独校验和是旧的, GNU tar 同样判定「这不是有效头」。这一条最隐蔽。

陷阱:问题态里其实有一行「看起来在修 size」的代码:

new_size = raw_size + (len(new_block[124:136]) - len(block[124:136]))

实测两个长度恒等于 12(切片按绝对偏移走),所以 new_size == raw_size, 这个补丁等于没写。用固定偏移切片去量「整体长度变化」永远量不出来。

缺陷③ 数据区替换后长度不守恒

out += sanitize(body, rules)      # 替换后变短,声明 size 没跟着变
# 流式分支还有:按 4KiB 切块替换 → 跨块边界的敏感值替换不到

实测:原始数据区 2200 字节 -> 替换后 1900 字节(差 300), 而头区声明的 size 仍是原始值 → 下一个头的偏移就错了。

4KiB 分块还有个安静泄漏:敏感串横跨 4096 边界时两边都匹配不上, 一半敏感串原样留在归档里,而 grep -F '/srv/data' 可能刚好搜不到完整串,判定还是绿的。

缺陷④ 结尾零块按输入长度补

if n_stream == 0:                       # 有成员走流式就完全不补了
    out = _pad_eof_tail(out, total_in)  # total_in 传进去,函数体里根本没用

def _pad_eof_tail(out, total_in):
    while len(out) % BLOCK: out += b"\x00"
    return out                          # 只做 512 对齐,没保证 ≥2 块零块

三层错:只在 n_stream==0 时调用、参数没用、没保证 ≥2 块零块。 本题有 4 个成员走流式分支,所以结尾从来没被补过。

缺陷⑤ 坏了没人知道

conf/protect.env 的 SELF_CHECK=0,代码里也没有任何读它做自检的逻辑。


四、解决办法

只改两个文件:app/tarfilter.py、conf/protect.env。

修① 状态每个成员重新判定

if hit:
    n_skip += 1
    pos += advance
    continue
streaming = size > threshold      # 只看当前成员,不沿用上一轮

修② 只改定长字段 + 重算 checksum

def put_str_field(field, value):
    width = len(field)
    if len(value) > width: return None
    return value + b"\x00" * (width - len(value))

def header_checksum(block):
    total = 0
    for i, byte in enumerate(block):
        total += 32 if 148 <= i < 156 else byte   # 校验和字段自己按 8 个空格算
    return ("%06o" % total).encode() + b"\x00 "

def rewrite_header(block, rules):
    raw_name, raw_prefix = block[0:100], block[345:357]
    name_field   = put_str_field(raw_name,   sanitize(_cstr(raw_name),   rules))
    prefix_field = put_str_field(raw_prefix, sanitize(_cstr(raw_prefix), rules))
    if name_field is None or prefix_field is None:
        return None
    rebuilt = name_field + block[100:345] + prefix_field + block[357:]
    return rebuilt[:148] + header_checksum(rebuilt) + rebuilt[156:]

sanitize() 只能用在数据内容上,不能用在头区。

修③ 数据区长度守恒

def sanitize_pad_to(buf, rules, target_len):
    new = sanitize(buf, rules)
    if len(new) < target_len:   new += b"\x00" * (target_len - len(new))
    elif len(new) > target_len: new = new[:target_len]
    return new

out += sanitize_pad_to(body, rules, size) + body_pad
if len(out) % BLOCK:
    raise AssertionError("输出未按 512 对齐,成员边界已错位")   # 立刻炸在自己身上

同时去掉 4KiB 分块,避免跨块边界的敏感值漏替换。

修④ 按输出长度补结尾

def pad_eof_tail(out):
    while len(out) % BLOCK:                     # 512 对齐
        out += b"\x00"
    zeros, q = 0, len(out) - BLOCK
    while q >= 0 and out[q:q+BLOCK].strip(b"\x00") == b"":
        zeros += 1; q -= BLOCK
    while zeros < 2:                            # 至少两块零块
        out += b"\x00" * BLOCK; zeros += 1
    while len(out) % (BLOCKING_FACTOR * BLOCK): # 归档块对齐 20×512
        out += b"\x00" * BLOCK
    return out

修⑤ 让自检真的拦得住

docker compose exec app bash ops/set_conf.sh SELF_CHECK 1
report = self_check(out)      # 逐块走一遍输出
if str(cfg.get("SELF_CHECK", "0")) == "1" and not report["ok"]:
    sys.stderr.write("[tarfilter] SELF-CHECK FAILED: %r\n" % (report,))
    raise SystemExit(3)       # 坏归档绝不以 0 退出

self_check() 核对四项:len_mod_512 == 0、n_bad_cksum == 0、n_members > 0、trailing_zero_blocks >= 2。


五、验证

5.1 判定 + 业务验收

== 1. 跑过滤器(就是线上那条管道)==
   [tarfilter] TARFILTER_FIXED_V1 in=30720 out=30720 members=16 streamed=4 skipped=1 self_check=OK
   输出归档 .../archive.out.tar (30720 字节)  退出码=0

== B. 归档能不能完整读出来 ==
  [PASS] B1 tar -tf 列出了 15 个成员(完整,exit=0)
  [PASS] B2 tar -xf 解出 7 个文件(数据取得到)

== E. 归档结构完整性 ==
   len_mod_512=0 / n_members=15 / n_bad_cksum=0 / trailing_zero_blocks=10

==================== 判定结果 ====================
PASS=18 FAIL=0
结论:问题已解决

ops/accept.sh → RESULT: OK —— 归档可用且已脱敏

5.2 逐成员核对

注意:改名会让前面成员变短、后面成员的绝对偏移全部前移, 所以不能拿同一偏移对照原始/过滤后的头(我第一次就是这么误判的)。 正确做法是对两个归档各自独立逐块走一遍再按下标配对:

./                                     ./                          size 0->0       cksum=True OK
./music/曲库/                           ./music/曲库/                size 0->0       cksum=True OK
./music/曲库/日本語の歌.mp3                ./music/曲库/日本語の歌.mp3     size 6->6       cksum=True OK
./music/track01.mp3                    ./music/track01.mp3          size 6669->6669 cksum=True OK
./music/skipme.tmp                     (已按 EXCLUDE 跳过)
./backup/db.yaml.gz                    ./backup/db.yaml.<BLOCKED>   size 3337->3337 cksum=True OK
./logs/service.log                     ./logs/service.log           size 4001->4001 cksum=True OK
./notes/readme.txt                     ./notes/readme.txt           size 28->28     cksum=True OK
./srv/data/                            .<PATH>/                     size 0->0       cksum=True OK
./srv/data/small_conf.bin              .<PATH>/small_conf.bin       size 1201->1201 cksum=True OK
./srv/data/passwd.txt                  .<PATH>/passwd.txt           size 30->30     cksum=True OK

原始成员 16 个,输出成员 15 个
全部 checksum 自洽: True
全部体积守恒    : True

5.3 追加验证(防「只为过关」)

gzip -c out.tar > out.tgz && tar -tzf out.tgz | wc -l     # 15  ← 线上真实姿势
tar -xf out.tar -C x && find x -type f | wc -l            # 7
tar -tf out.tar | grep -c "PATH\|BLOCKED\|SECRETNAME"     # 4   ← 名字也清了
python3 app/tarfilter.py < out.tar > out2.tar && tar -tf out2.tar | wc -l   # 15 ← 幂等

四条全过,含幂等性(过滤两次仍可用)。

5.4 反向验证(证明判据有判别力)

把问题态放回去:

  [FAIL] B1 tar -tf 只列出 5 个成员(期望 15,exit=0)
  [FAIL] C1 成员数量不对:实得 5,期望 15
  [FAIL] E3 逐块走只解析出 5 个成员,期望 15
PASS=13 FAIL=5
结论:问题仍然存在(或修复不完整)

判定重新变红 → 判据有判别力。

5.5 稳定性

docker compose down -v → up -d --build → 再跑:PASS=18 FAIL=0、RESULT: OK,两次一致。


六、经验与避坑

  1. 「退出码 0」不等于「归档能用」。 GNU tar 对畸形归档尽力而为 + 静默截断:跳着读、exit 0、stderr 一句不报。 gzip -t 只说明压缩层没坏,与 tar 结构无关。 唯一可信的判据是结构本身:逐块走一遍,核对成员数 / 声明体积 / 头区 checksum / 结尾零块。
  2. 判据清单(哪条可信)
判据可信吗
过滤器退出码❌ 问题态也是 0
tar -tf 的 exit code❌ 静默截断也给 0
gzip -t❌ 只看压缩层
归档非空 / 前几个成员能列出❌ 问题态就是这样
「压缩比好看」❌ 空洞/畸形流常常压得更好
逐块走:成员数 / 体积 / checksum / 结尾零块✅
  1. 在定长二进制结构上做文本替换,永远先问两句: 替换后长度变了没有?还有哪些字段依赖这段字节或它的长度? tar 头区只是这个坑最经典的一个现场(同类:PE/ELF 头、PNG chunk、protobuf 定长字段、定长报文)。
  2. 改了内容依赖的字段,就要重算派生字段。 改 name → 必须重算 chksum。这类「派生字段」还有 size(改名不影响,改数据影响)、 prefix(长名分流)等。漏一个就是「结构看着完全正常、tar 就是读不了」。
  3. 用固定偏移切片量不出「整体长度变化」。len(new_block[124:136]) - len(block[124:136]) 恒等于 0,是个假补丁。 要判断「块长度变了没」,直接 len(new) - len(old)。
  4. 状态变量在循环内不复位,是流式处理最经典的 bug 形状。streaming / skipping 这种「置位后不复位」的写法, 症状是「前 N 个成员正常、后面整段消失」。 指纹:输出长度 ≈ 第一个异常成员之前的长度。
  5. 分块处理会让「跨块边界的模式」漏匹配。 4KiB 分块替换敏感串时,横跨边界的串两边都匹配不上,一半敏感数据原样留在归档里 —— 而且可能搜不出来(grep 找不到完整串)。要做「先整体替换、再补长度」。
  6. 对照两个被改写过的归档时,别用同一偏移。 改名/改长会让后续所有成员的绝对偏移前移。 正确做法:各自独立 while 走一遍(pos += 512 + ceil(size/512)*512),再按下标配对。 我第一次就因此误判,差点去查没坏的地方。
  7. 用 grep -F 而不是正则判敏感值。.gz 里的 . 在正则里是「任意字符」,会命中 Xgz、 gz,判定会失真。
  8. 自检要真的做,且要真的能拦住。SELF_CHECK=1 只是开了开关,代码得读它并逐块核验,坏了要 非 0 退出。 否则就是本题的教训重演:坏归档一路绿灯传到外部存储。
  9. 发布前加不变量断言。if len(out) % BLOCK: raise 这类断言让错误炸在过滤器自己身上, 而不是留给下游 tar 去「尽力而为」—— 后者恰恰是问题难被发现的原因。

七、一键复现

git fetch origin
git checkout 2026-09-21-0000
cd scenario/2026-09-21-0000
docker compose up -d --build

docker compose exec app bash ops/verify.sh   # 修复后 PASS=18 FAIL=0
docker compose exec app bash ops/accept.sh   # 修复后 RESULT: OK

# 看问题态
cp fix/tarfilter.problem.py app/tarfilter.py
docker compose exec app bash ops/verify.sh   # 变红
git checkout app/tarfilter.py                # 还原

docker compose down -v

本题环境:Debian bookworm + GNU tar 1.34 + Python 3.11.2。 tar 对畸形归档的行为在不同版本上不一样,钉版本才能稳定复现。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-21-0010-tar-skipping-to-next-header

分享这篇文章