180 lines
7.1 KiB
Shell
Executable file
180 lines
7.1 KiB
Shell
Executable file
#!/bin/bash
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
# Copyright (c) 2025-2026 Cristian Cezar Moisés
|
|
# Bug #16 regression — Block-swap attack on encrypted archives.
|
|
#
|
|
# Pre-fix vulnerability:
|
|
# AES-CTR + HMAC-SHA256 in zupt 2.2.2 covered MAC over (nonce || ciphertext)
|
|
# only. The decryptor read the nonce from the package itself and ignored
|
|
# the block_seq parameter. An attacker who swapped two valid encrypted
|
|
# blocks (header + payload) between positions could produce an archive
|
|
# that decrypts cleanly but extracts files with the wrong content.
|
|
#
|
|
# Fix:
|
|
# Bind block_seq into MAC as 8-byte LE AAD. Encrypt is now MAC-over
|
|
# (nonce || ciphertext || block_seq_LE). Decrypt tries v2 first, falls
|
|
# back to legacy v1 for old archives. Per-file block_seq is used so
|
|
# extract-side counter matches encrypt-side without needing extra
|
|
# index metadata.
|
|
#
|
|
# This test:
|
|
# 1. Creates an encrypted archive with two distinct files A and B
|
|
# 2. Performs the block-swap surgery on the binary
|
|
# 3. Verifies extract REJECTS the swapped archive (auth failure)
|
|
# 4. Also verifies normal extract still works (regression guard)
|
|
|
|
ZUPT_BIN="$(realpath ./zupt)"
|
|
TMPDIR=$(mktemp -d)
|
|
trap "rm -rf $TMPDIR" EXIT
|
|
cd "$TMPDIR"
|
|
|
|
PASS=0; FAIL=0
|
|
chk() {
|
|
if [ $? -eq 0 ]; then echo " ✓ $1"; PASS=$((PASS+1))
|
|
else echo " ✗ $1"; FAIL=$((FAIL+1)); fi
|
|
}
|
|
|
|
echo " [Bug #16 — Block-swap (reorder) attack defense]"
|
|
|
|
# Two distinct files, small enough that each fits in one block
|
|
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' > file_A.txt
|
|
printf 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n' > file_B.txt
|
|
|
|
# Build encrypted archive (small block to ensure 1 block per file)
|
|
"$ZUPT_BIN" c -p mypassword -b 64 -t 1 archive.zupt file_A.txt file_B.txt > /dev/null 2>&1
|
|
|
|
# P1: Normal extract still works (regression guard)
|
|
mkdir extract_normal
|
|
"$ZUPT_BIN" x archive.zupt -p mypassword -o extract_normal > /dev/null 2>&1
|
|
[ -f extract_normal/file_A.txt ] && [ -f extract_normal/file_B.txt ] && \
|
|
diff -q file_A.txt extract_normal/file_A.txt > /dev/null && \
|
|
diff -q file_B.txt extract_normal/file_B.txt > /dev/null
|
|
chk "Normal extract still works (regression guard)"
|
|
|
|
# P2: The block-swap attack must FAIL (no files extracted, or wrong files rejected)
|
|
python3 << 'PYEOF'
|
|
import sys, struct
|
|
data = bytearray(open('archive.zupt','rb').read())
|
|
# Find DATA blocks via block magic 0xbb 0x01 + block_type=DATA(0)
|
|
def parse_blocks(data):
|
|
blocks = []
|
|
i = 0
|
|
while i < len(data) - 7:
|
|
if data[i] == 0xbb and data[i+1] == 0x01:
|
|
block_type = data[i+2]
|
|
codec = struct.unpack('<H', bytes(data[i+3:i+5]))[0]
|
|
flags = struct.unpack('<H', bytes(data[i+5:i+7]))[0]
|
|
# parse varint uncomp
|
|
idx = i + 7
|
|
uncomp, shift = 0, 0
|
|
while idx < len(data):
|
|
b = data[idx]
|
|
uncomp |= (b & 0x7F) << shift
|
|
idx += 1
|
|
if not (b & 0x80): break
|
|
shift += 7
|
|
comp, shift = 0, 0
|
|
while idx < len(data):
|
|
b = data[idx]
|
|
comp |= (b & 0x7F) << shift
|
|
idx += 1
|
|
if not (b & 0x80): break
|
|
shift += 7
|
|
payload_start = idx + 8 # skip 8-byte checksum
|
|
block_end = payload_start + comp
|
|
blocks.append({
|
|
'type': block_type, 'flags': flags,
|
|
'start': i, 'end': block_end, 'comp': comp,
|
|
})
|
|
i = block_end
|
|
else:
|
|
i += 1
|
|
return blocks
|
|
|
|
blocks = parse_blocks(data)
|
|
data_blocks = [b for b in blocks if b['type'] == 0 and b['flags'] & 0x01]
|
|
if len(data_blocks) < 2:
|
|
print(f"Found only {len(data_blocks)} encrypted DATA blocks; can't swap", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
# Swap the first two DATA blocks
|
|
B0 = bytes(data[data_blocks[0]['start']:data_blocks[0]['end']])
|
|
B1 = bytes(data[data_blocks[1]['start']:data_blocks[1]['end']])
|
|
swapped = bytearray(data)
|
|
# Swap (assume same size)
|
|
if len(B0) != len(B1):
|
|
print(f"different block sizes {len(B0)} vs {len(B1)}; can't swap directly", file=sys.stderr)
|
|
sys.exit(2)
|
|
swapped[data_blocks[0]['start']:data_blocks[0]['end']] = B1
|
|
swapped[data_blocks[1]['start']:data_blocks[1]['end']] = B0
|
|
open('archive_swapped.zupt','wb').write(bytes(swapped))
|
|
print(f"swap done", file=sys.stderr)
|
|
PYEOF
|
|
swap_status=$?
|
|
|
|
if [ $swap_status -eq 0 ]; then
|
|
mkdir extract_attack
|
|
out=$("$ZUPT_BIN" x archive_swapped.zupt -p mypassword -o extract_attack 2>&1)
|
|
rc=$?
|
|
|
|
# Attack defense check: at least one of these must hold
|
|
# - rc != 0 (extract returned error)
|
|
# - no files extracted
|
|
# - extracted files have wrong content (we reject this — would mean the
|
|
# bug is still present)
|
|
a_swapped=0; b_swapped=0
|
|
[ -f extract_attack/file_A.txt ] && cmp -s file_B.txt extract_attack/file_A.txt && a_swapped=1
|
|
[ -f extract_attack/file_B.txt ] && cmp -s file_A.txt extract_attack/file_B.txt && b_swapped=1
|
|
|
|
if [ "$a_swapped" = "1" ] && [ "$b_swapped" = "1" ]; then
|
|
# BAD: attack succeeded — file_A has B's content and vice versa
|
|
false
|
|
else
|
|
# GOOD: attack rejected (either error, no files, or files unchanged)
|
|
true
|
|
fi
|
|
chk "Block-swap attack rejected (cross-file reorder)"
|
|
else
|
|
echo " ⊘ Block-swap attack test skipped (couldn't locate block boundaries)"
|
|
fi
|
|
|
|
# P3: Single-block file (boundary case — empty seq_AAD doesn't degenerate)
|
|
echo "single block content" > tiny.txt
|
|
"$ZUPT_BIN" c -p mypassword tiny.zupt tiny.txt > /dev/null 2>&1
|
|
mkdir tiny_out
|
|
"$ZUPT_BIN" x tiny.zupt -p mypassword -o tiny_out > /dev/null 2>&1
|
|
[ -f tiny_out/tiny.txt ] && diff -q tiny.txt tiny_out/tiny.txt > /dev/null
|
|
chk "Single-block file roundtrip (boundary)"
|
|
|
|
# P4: Multi-block large file (ensures every block has correct AAD seq)
|
|
dd if=/dev/urandom of=big.bin bs=1024 count=512 2>/dev/null
|
|
"$ZUPT_BIN" c -p mypassword big.zupt big.bin > /dev/null 2>&1
|
|
mkdir big_out
|
|
"$ZUPT_BIN" x big.zupt -p mypassword -o big_out > /dev/null 2>&1
|
|
[ -f big_out/big.bin ] && diff -q big.bin big_out/big.bin > /dev/null
|
|
chk "512KB multi-block file roundtrip"
|
|
|
|
# P5: Multiple files in one archive (each gets per-file seq counter)
|
|
echo "first" > a.txt
|
|
echo "second" > b.txt
|
|
echo "third" > c.txt
|
|
"$ZUPT_BIN" c -p mypassword multi.zupt a.txt b.txt c.txt > /dev/null 2>&1
|
|
mkdir multi_out
|
|
"$ZUPT_BIN" x multi.zupt -p mypassword -o multi_out > /dev/null 2>&1
|
|
[ -f multi_out/a.txt ] && [ -f multi_out/b.txt ] && [ -f multi_out/c.txt ] && \
|
|
diff -q a.txt multi_out/a.txt > /dev/null && \
|
|
diff -q b.txt multi_out/b.txt > /dev/null && \
|
|
diff -q c.txt multi_out/c.txt > /dev/null
|
|
chk "Multi-file archive roundtrip (per-file seq counters)"
|
|
|
|
# P6: Wrong password still fails cleanly
|
|
mkdir wrong_pw
|
|
out=$("$ZUPT_BIN" x archive.zupt -p WRONG_PASSWORD -o wrong_pw 2>&1)
|
|
[ ! -f wrong_pw/file_A.txt ]
|
|
chk "Wrong password rejected"
|
|
|
|
echo
|
|
echo " ───────────────────────────────────────"
|
|
echo " Block-swap regression: $PASS passed, $FAIL failed"
|
|
echo " ───────────────────────────────────────"
|
|
[ $FAIL -eq 0 ]
|