Skip to content

Commit 48894a9

Browse files
authored
Merge pull request #23 from flatcar/kai/decode-helper
Add decode_payload script to extract and verify update payloads
2 parents c6f566d + eac6138 commit 48894a9

File tree

1 file changed

+150
-0
lines changed

1 file changed

+150
-0
lines changed

decode_payload

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
4+
CHECKINPUT=0
5+
SCRIPTFOLDER="$(dirname "$(readlink -f "$0")")"
6+
7+
if [ $# -lt 3 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
8+
echo "Usage: $0 PUBKEY PAYLOAD OUTPUT [KERNELOUTPUT]"
9+
echo "Decodes a payload, writing the decoded payload to stdout and payload information to stderr"
10+
echo "Only one optional kernel payload is supported (the output file will be zero-sized if no payload was found)."
11+
echo "Only a signle signature is supported as only one pubkey is accepted and in the multi-signature case only the second signature is looked at."
12+
echo "Pass CHECKINPUT=1 to process the inline checksums in addition to the final checksum."
13+
exit 1
14+
fi
15+
16+
PUBKEY="$1"
17+
FILE="$2"
18+
PARTOUT="$3"
19+
KERNEL="${4-}"
20+
21+
# The format is documented in src/update_engine/update_metadata.proto
22+
# The header itself is not a protobuf and we can extract the manifest protobuf size as big endian uint64 from offset 12 and convert it to a decimal string
23+
MLEN=$(dd status=none bs=1 skip=12 count=8 if="${FILE}" | od --endian=big -An -vtu8 -w1024 | tr -d ' ')
24+
25+
# The manifest starts at offset 20 (with tail we do a +1 compared to dd) and we feed it into protoc for decoding (we assume that the text output format is stable)
26+
DESC=$(protoc --decode=chromeos_update_engine.DeltaArchiveManifest --proto_path "${SCRIPTFOLDER}"/src/update_engine "${SCRIPTFOLDER}"/src/update_engine/update_metadata.proto < <({ tail -c +21 "${FILE}" || true ; } |head -c "${MLEN}"))
27+
28+
echo "${DESC}" >&2
29+
30+
# Truncate
31+
true > "${PARTOUT}"
32+
if [ "${KERNEL}" != "" ]; then
33+
true > "${KERNEL}"
34+
fi
35+
36+
PARALLEL=$(nproc)
37+
RUNNING=0
38+
# MODE is the current parsing context, OUT is either PARTOUT or KERNEL
39+
# A new context/mode should reset the state variables it expects to get set (e.g., DATAHASH, or SIZE and HASH)
40+
# FINALSIZE/-HASH and KERNELSIZE/-HASH store the section values for later
41+
MODE="" OUT="" CAT="cat" OFFSET=0 LENGTH=0 START=0 NUM_BLOCKS=0 DATAHASH="" HASH="" SIZE=0 FINALSIZE=0 FINALHASH="" KERNELSIZE=0 KERNELHASH="" SIGOFFSET=0 SIGSIZE=0
42+
while IFS= read -r LINE; do
43+
LINE=$(echo "${LINE}" | sed 's/^ *//g')
44+
case "${LINE}" in
45+
"partition_operations {") MODE="partition_operations" DATAHASH="" OUT="${PARTOUT}" ;; # Each of these sections has a part of the split payload
46+
"type: REPLACE_BZ") CAT="bzcat" ;; # The payload part is compressed if it reduces the size
47+
"type: REPLACE") CAT="cat" ;;
48+
"data_offset:"*) OFFSET=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; # Read pointers into the payload, in bytes
49+
"data_length:"*) LENGTH=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;;
50+
"start_block:"*) START=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; # Part of dst_extents, write pointer for the target, in blocks
51+
"data_sha256_hash:"*) DATAHASH=$(echo "${LINE}" | cut -d '"' -f 2- | head -c-2 | sed 's/%/%%/g') ;; # Expected to be the last entry,
52+
# almost printf-able but we have to escape % and truncate the closing quote because we can't split by quotes as it may contain them
53+
"num_blocks:"*) NUM_BLOCKS=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;; # Part of dst_extents, not used because dd doesn't need it
54+
"block_size:"*) ;; # Comes after partition_operations, assumed to be 4096, not checked
55+
"signatures_offset:"*) SIGOFFSET=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;;
56+
"signatures_size:"*) SIGSIZE=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;;
57+
"dst_extents {") ;; # Part of partition_operations or operations
58+
"noop_operations {") MODE="noop_operations" DATAHASH="" OUT="" ;; # Comes after partition_operations, ignored
59+
"new_partition_info {") MODE="new_partition_info" SIZE=0 HASH="" OUT="" ;;
60+
"hash:"*) HASH=$(echo "${LINE}" | cut -d '"' -f 2- | head -c-2 | sed 's/%/%%/g') ;; # Part of new_partition_info
61+
"size:"*) SIZE=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;;
62+
"new_info {") MODE="new_info" SIZE=0 HASH="" OUT="" ;;
63+
"procedures {") MODE="procedures" DATAHASH="" OUT="" ;; # Note that only one kernel is supported and nothing else
64+
"operations {") MODE="operations" DATAHASH="" ;; # Nested in "procedures", each of these sections has a part of the split payload
65+
"type: KERNEL") if [ "${KERNEL}" != "" ]; then OUT="${KERNEL}" ; fi ;;
66+
"}") ;;
67+
*) echo "unmatched: $LINE" >&2 ;;
68+
esac
69+
if [ "${OUT}" != "" ] && [ "${DATAHASH}" != "" ]; then
70+
(
71+
# The payload data split in parts starts after the head and the manifest, and from there we have the variable offset for each part
72+
{ { tail -c +$((21 + MLEN + OFFSET)) "${FILE}" || true ; } | head -c "${LENGTH}" | "${CAT}" | dd status=none bs=4096 seek="${START}" of="${OUT}" ; } || { echo "Write error" >&2 ; exit 1 ; }
73+
# Each part has a checksum but since we also have one at the end it's not really that meaningful to check, so only done conditionally
74+
if [ "${CHECKINPUT}" = 1 ] && [ "$({ tail -c +$((21 + MLEN + OFFSET)) "${FILE}" || true ; } | head -c "${LENGTH}" | sha256sum | cut -d ' ' -f 1)" != "$(printf -- "${DATAHASH}" | od -An -vtx1 -w1024 | tr -d ' ')" ]; then
75+
echo "Data hash mismatch" >&2
76+
exit 1 # Script will continue and fail at the end
77+
fi
78+
) &
79+
DATAHASH=""
80+
RUNNING=$((RUNNING + 1))
81+
fi
82+
if [ "${RUNNING}" = "${PARALLEL}" ]; then
83+
wait
84+
RUNNING=0
85+
fi
86+
if [ "${MODE}" = "new_partition_info" ] && [ "${SIZE}" != 0 ]; then
87+
FINALSIZE="${SIZE}"
88+
fi
89+
if [ "${MODE}" = "new_partition_info" ] && [ "${HASH}" != "" ]; then
90+
FINALHASH="${HASH}"
91+
fi
92+
if [ "${MODE}" = "new_info" ] && [ "${SIZE}" != 0 ]; then
93+
KERNELSIZE="${SIZE}"
94+
fi
95+
if [ "${MODE}" = "new_info" ] && [ "${HASH}" != "" ]; then
96+
KERNELHASH="${HASH}"
97+
fi
98+
done <<< "${DESC}"
99+
wait
100+
101+
if [ "$(stat '-c%s' "${PARTOUT}")" != "${FINALSIZE}" ]; then
102+
echo "Size mismatch" >&2
103+
exit 1
104+
fi
105+
if [ "$(printf -- "${FINALHASH}" | od -An -vtx1 -w1024 | tr -d ' ')" != "$(sha256sum "${PARTOUT}" | cut -d ' ' -f 1)" ]; then
106+
echo "Hash mismatch" >&2
107+
exit 1
108+
fi
109+
if [ "${KERNEL}" != "" ] && [ "$(stat '-c%s' "${KERNEL}")" != "${KERNELSIZE}" ]; then
110+
echo "Kernel size mismatch" >&2
111+
exit 1
112+
fi
113+
if [ "${KERNEL}" != "" ] && [ "$(printf -- "${KERNELHASH}" | od -An -vtx1 -w1024 | tr -d ' ')" != "$(sha256sum "${KERNEL}" | cut -d ' ' -f 1)" ]; then
114+
echo "Kernel hash mismatch" >&2
115+
exit 1
116+
fi
117+
118+
# The signature protobuf message is at the signature offset
119+
# Decoding the "data" field caues some troubles and needs a workaround below for the dev key
120+
SIGDESC=$(protoc --decode=chromeos_update_engine.Signatures --proto_path "${SCRIPTFOLDER}"/src/update_engine "${SCRIPTFOLDER}"/src/update_engine/update_metadata.proto < <({ tail -c +$((21 + MLEN + SIGOFFSET)) "${FILE}" || true ; } |head -c "${SIGSIZE}"))
121+
echo "${SIGDESC}" >&2
122+
VERSION=2 # Init for the single-signature case, in the many-signature case the first signature ("version 1" but better would be "number 1") is,
123+
# at least for Flatcar production, a dummy and parsing it overwrites this variable with 1 and it will be ignored,
124+
# the second signature is "version 2" and the one we want to check. Even if only one signature is there it becomes "version 2",
125+
# see https://github.com/flatcar/update_engine/blob/c6f566d47d8949632f7f43871eb8d5c625af3209/src/update_engine/payload_signer.cc#L33
126+
while IFS= read -r LINE; do
127+
LINE=$(echo "${LINE}" | sed 's/^ *//g')
128+
case "${LINE}" in
129+
"version:"*) VERSION=$(echo "${LINE}" | cut -d : -f 2 | tr -d ' ') ;;
130+
"data:"*)
131+
SIGDATA=$(echo "${LINE}" | cut -d '"' -f 2- | head -c-2 | sed 's/%/%%/g')
132+
# This is a workaround for the dev-key vs prod-key case: sed '/signatures {/d' | sed '/ version: 2/d'
133+
SIGHEX=$(printf -- "${SIGDATA}" | sed '/signatures {/d' | sed '/ version: 2/d' | openssl rsautl -verify -pubin -inkey "${PUBKEY}" -raw | tail -c 32 | od -An -vtx1 -w1024 | tr -d ' ')
134+
# The raw output instead of asn1parse is used to easily extract the sha256 checksum (done by tail -c 32)
135+
# We also calculate the payload hash that the signature was done for, note that it's of course not the whole file but only up to the attached signature itself
136+
PAYLOADHASH=$(head -c "$((20 + MLEN + SIGOFFSET))" "${FILE}" | sha256sum | cut -d ' ' -f 1)
137+
if [ "${VERSION}" = 2 ] && [ "${SIGHEX}" != "${PAYLOADHASH}" ]; then
138+
echo "Signature error" >&2
139+
exit 1
140+
elif [ "${VERSION}" != 2 ]; then
141+
# For Flatcar production this is a dummy signature with a random key,
142+
# see https://github.com/flatcar/flatcar-build-scripts/blob/821d8da19567e3d1a29dc24f8c822f67df6a5e02/generate_payload#L384
143+
echo "Unprocessed 'version ${VERSION}' signature (Payload hash: ${PAYLOADHASH}, SIGDATA: ${SIGDATA})" >&2
144+
fi
145+
;;
146+
*) ;;
147+
esac
148+
done <<< "${SIGDESC}"
149+
150+
echo "Success" >&2

0 commit comments

Comments
 (0)