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