Skip to content

Commit 528331e

Browse files
Feature(decryption): add ubifs decryption support (fscrypt V1 policy)
1 parent 257e17d commit 528331e

11 files changed

Lines changed: 434 additions & 33 deletions

File tree

poetry.lock

Lines changed: 151 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ readme = "README.md"
88
packages = [{include = "ubireader"}]
99

1010
[tool.poetry.dependencies]
11-
python = ">=3.9"
11+
python = ">=3.9.2"
1212
lzallright = "^0.2.1"
13+
cryptography = "^44.0.2"
1314

1415
[build-system]
1516
requires = ["poetry-core"]

ubireader/scripts/ubireader_extract_files.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ def main():
8686
parser.add_argument('-o', '--output-dir', dest='outpath',
8787
help='Specify output directory path.')
8888

89+
parser.add_argument('-K', '--master-key', dest='master_key',
90+
help='Master key file, given with fscryptctl e.g. to encrypt the UBIFS (support limited to fscrypt v1 policies)')
91+
8992
parser.add_argument('filepath', help='File to extract contents of.')
9093

9194
if len(sys.argv) == 1:
@@ -104,6 +107,19 @@ def main():
104107

105108
settings.uboot_fix = args.uboot_fix
106109

110+
if args.master_key:
111+
path = args.master_key
112+
if not os.path.exists(path):
113+
parser.error("File path doesn't exist.")
114+
else :
115+
with open(path, "rb") as file:
116+
if os.stat(path).st_size != 64:
117+
parser.error("Master key file size is not 64 bytes.")
118+
else:
119+
master_key = file.read(64)
120+
else:
121+
master_key = None
122+
107123
if args.filepath:
108124
path = args.filepath
109125
if not os.path.exists(path):
@@ -145,7 +161,6 @@ def main():
145161

146162
# Create file object.
147163
ufile_obj = ubi_file(path, block_size, start_offset, end_offset)
148-
149164
if filetype == UBI_EC_HDR_MAGIC:
150165
# Create UBI object
151166
ubi_obj = ubi(ufile_obj)
@@ -179,14 +194,14 @@ def main():
179194
lebv_file = leb_virtual_file(ubi_obj, vol_blocks)
180195

181196
# Extract files from UBI image.
182-
ubifs_obj = ubifs(lebv_file)
197+
ubifs_obj = ubifs(lebv_file, master_key=master_key)
183198
print('Extracting files to: %s' % vol_outpath)
184199
extract_files(ubifs_obj, vol_outpath, perms)
185200

186201

187202
elif filetype == UBIFS_NODE_MAGIC:
188203
# Create UBIFS object
189-
ubifs_obj = ubifs(ufile_obj)
204+
ubifs_obj = ubifs(ufile_obj, master_key=master_key)
190205

191206
# Create directory for files.
192207
create_output_dir(outpath)

ubireader/scripts/ubireader_list_files.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ def main():
7878
parser.add_argument('-D', '--copy-dest', dest='copyfiledest',
7979
help='Copy Destination.')
8080

81+
parser.add_argument('-K', '--master-key', dest='master_key',
82+
help='Master key file, given with fscryptctl e.g. to encrypt the UBIFS (support limited to fscrypt v1 policies)')
83+
8184
parser.add_argument('filepath', help='UBI/UBIFS image file.')
8285

8386
if len(sys.argv) == 1:
@@ -96,6 +99,19 @@ def main():
9699

97100
settings.uboot_fix = args.uboot_fix
98101

102+
if args.master_key:
103+
path = args.master_key
104+
if not os.path.exists(path):
105+
parser.error("File path doesn't exist.")
106+
else :
107+
with open(path, "rb") as file:
108+
if os.stat(path).st_size != 64:
109+
parser.error("Master key file size is not 64 bytes.")
110+
else:
111+
master_key = file.read(64)
112+
else:
113+
master_key = None
114+
99115
if args.filepath:
100116
path = args.filepath
101117
if not os.path.exists(path):
@@ -154,7 +170,7 @@ def main():
154170
lebv_file = leb_virtual_file(ubi_obj, vol_blocks)
155171

156172
# Create UBIFS object.
157-
ubifs_obj = ubifs(lebv_file)
173+
ubifs_obj = ubifs(lebv_file, master_key=master_key)
158174

159175
if args.listpath:
160176
list_files(ubifs_obj, args.listpath)
@@ -163,8 +179,7 @@ def main():
163179

164180
elif filetype == UBIFS_NODE_MAGIC:
165181
# Create UBIFS object
166-
ubifs_obj = ubifs(ufile_obj)
167-
182+
ubifs_obj = ubifs(ufile_obj, master_key=master_key)
168183
if args.listpath:
169184
list_files(ubifs_obj, args.listpath)
170185
if args.copyfile and args.copyfiledest:

ubireader/ubifs/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ubireader.debug import error, log, verbose_display
2121
from ubireader.ubifs.defines import *
2222
from ubireader.ubifs import nodes, display
23+
from typing import Optional
2324

2425
class ubifs():
2526
"""UBIFS object
@@ -35,9 +36,11 @@ class ubifs():
3536
Obj:mst_node -- Master Node of UBIFS image LEB1
3637
Obj:mst_node2 -- Master Node 2 of UBIFS image LEB2
3738
"""
38-
def __init__(self, ubifs_file):
39+
def __init__(self, ubifs_file, master_key: Optional[bytes] = None):
3940
self.__name__ = 'UBIFS'
4041
self._file = ubifs_file
42+
self.master_key = master_key
43+
4144
try:
4245
self.file.reset()
4346
sb_chdr = nodes.common_hdr(self.file.read(UBIFS_COMMON_HDR_SZ))

ubireader/ubifs/decrypt.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from ubireader.ubifs.defines import UBIFS_XATTR_NAME_ENCRYPTION_CONTEXT
2+
from ubireader.debug import error
3+
from cryptography.hazmat.primitives.ciphers import (
4+
Cipher, algorithms, modes
5+
)
6+
7+
AES_BLOCK_SIZE = algorithms.AES.block_size // 8
8+
9+
def lookup_inode_nonce(inodes: dict, inode: dict) -> bytes:
10+
# get the extended attribute 'xent' of the inode
11+
if 'xent' not in inode or not inode['xent']:
12+
raise ValueError(f"No xent found for inode {inode}")
13+
14+
for xattr_inode in inode['xent']:
15+
if (xattr_inode.name == UBIFS_XATTR_NAME_ENCRYPTION_CONTEXT):
16+
nonce_ino = inodes[xattr_inode.inum]['ino']
17+
nonce = nonce_ino.data[-16:]
18+
if len(nonce) != 16:
19+
raise ValueError(f"Invalid nonce length for inode {inode}")
20+
return nonce
21+
22+
23+
def derive_key_from_nonce(master_key: bytes, nonce: bytes) -> bytes:
24+
encryptor = Cipher(
25+
algorithms.AES(nonce),
26+
modes.ECB(),
27+
).encryptor()
28+
derived_key = encryptor.update(master_key) + encryptor.finalize()
29+
return derived_key
30+
31+
32+
def filename_decrypt(key: bytes, ciphertext: bytes):
33+
34+
# using AES CTS-CBC mode not supported by pyca cryptography
35+
if len(ciphertext) > AES_BLOCK_SIZE:
36+
# Cipher Text Stealing Step
37+
pad = AES_BLOCK_SIZE - len(ciphertext) % AES_BLOCK_SIZE
38+
39+
if pad > 0: # Steal ciphertext only if needed (CTS)
40+
decryptor = Cipher(
41+
algorithms.AES(key[:32]),
42+
modes.ECB(),
43+
).decryptor()
44+
second_to_last = ciphertext[-2*AES_BLOCK_SIZE+pad:-AES_BLOCK_SIZE+pad]
45+
plaintext = decryptor.update(second_to_last) + decryptor.finalize()
46+
# Apply padding
47+
ciphertext += plaintext[-pad:]
48+
# Swap the last two blocks
49+
ciphertext = ciphertext[:-2*AES_BLOCK_SIZE] + ciphertext[-AES_BLOCK_SIZE:] + ciphertext[-2*AES_BLOCK_SIZE:-AES_BLOCK_SIZE]
50+
51+
# AES-CBC step
52+
NULL_IV = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
53+
54+
decryptor = Cipher(
55+
algorithms.AES(key[:32]),
56+
modes.CBC(NULL_IV),
57+
).decryptor()
58+
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
59+
return plaintext.rstrip(b'\x00')
60+
61+
62+
def datablock_decrypt(block_key: bytes, block_iv: bytes, block_data: bytes):
63+
decryptor = Cipher(
64+
algorithms.AES(block_key),
65+
modes.XTS(block_iv),
66+
).decryptor()
67+
return decryptor.update(block_data) + decryptor.finalize()
68+
69+
70+
def decrypt_filenames(ubifs, inodes):
71+
if ubifs.master_key is None:
72+
for inode in inodes.values():
73+
for dent in inode['dent']:
74+
dent.name = dent.raw_name.decode()
75+
return
76+
try:
77+
# for every node holding a cryptographic xattr, lookup the
78+
# nonce inode from the xattr 'inum' attr
79+
for inode in inodes.values():
80+
if "dent" not in inode:
81+
continue
82+
nonce = lookup_inode_nonce(inodes, inode)
83+
dec_key = derive_key_from_nonce(ubifs.master_key, nonce)
84+
for dent in inode['dent']:
85+
dent.name = filename_decrypt(dec_key, dent.raw_name).decode()
86+
except Exception as e:
87+
error(decrypt_filenames, 'Error', str(e))
88+
89+
90+
def decrypt_symlink_target(ubifs, inodes, dent_node) -> str:
91+
if ubifs.master_key is None:
92+
return inodes[dent_node.inum]['ino'].data.decode()
93+
inode = inodes[dent_node.inum]
94+
ino = inode['ino']
95+
nonce = lookup_inode_nonce(inodes, inode)
96+
name_offset = 2
97+
encrypted_name = ino.data[name_offset:-1]
98+
dec_key = derive_key_from_nonce(ubifs.master_key, nonce)
99+
lnkname = filename_decrypt(dec_key, encrypted_name)
100+
return lnkname.decode()

ubireader/ubifs/defines.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,20 @@
262262
# 'data', no size.
263263
UBIFS_INO_NODE_SZ = struct.calcsize(UBIFS_INO_NODE_FORMAT)
264264

265+
# Extended attribute node are identical to DENT_NODES
266+
UBIFS_XENT_NODE_FORMAT = '<%ssQBBHI' % (UBIFS_MAX_KEY_LEN)
267+
UBIFS_XENT_NODE_FIELDS = ['key', # Node key.
268+
'inum', # Target inode number.
269+
'padding1', # Reserved for future, zeros.
270+
'type', # Type of target inode.
271+
'nlen', # Name length.
272+
'cookie', # 32bit random number, used to
273+
# construct a 64bit identifier.
274+
]
275+
# 'name', no size.
276+
UBIFS_XENT_NODE_SZ = struct.calcsize(UBIFS_XENT_NODE_FORMAT)
277+
278+
UBIFS_XATTR_NAME_ENCRYPTION_CONTEXT = "c"
265279

266280
# Directory entry node
267281
UBIFS_DENT_NODE_FORMAT = '<%ssQBBHI' % (UBIFS_MAX_KEY_LEN)
@@ -282,8 +296,9 @@
282296
UBIFS_DATA_NODE_FIELDS = ['key', # Node key.
283297
'size', # Uncompressed data size.
284298
'compr_type', # Compression type UBIFS_COMPR_*.
285-
'compr_size', # Compressed data size in bytes
286-
# only valid when data is encrypted.
299+
'plaintext_size', # Compressed data size in bytes
300+
# before encryption only valid
301+
# when data is encrypted.
287302
]
288303
# 'data', no size.
289304
UBIFS_DATA_NODE_SZ = struct.calcsize(UBIFS_DATA_NODE_FORMAT)

ubireader/ubifs/list.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
import os
2121
import time
22+
import struct
23+
from ubireader.ubifs.decrypt import lookup_inode_nonce, derive_key_from_nonce, datablock_decrypt, decrypt_symlink_target
2224
from ubireader.ubifs.defines import *
2325
from ubireader.ubifs import walk
2426
from ubireader.ubifs.misc import decompress
@@ -39,9 +41,8 @@ def list_files(ubifs, list_path):
3941

4042
if len(inodes) < 2:
4143
raise Exception('No inodes found')
42-
44+
4345
inum = find_dir(inodes, 1, pnames, 0)
44-
4546
if inum == None:
4647
return
4748

@@ -86,7 +87,7 @@ def copy_file(ubifs, filepath, destpath):
8687

8788
for dent in inodes[inum]['dent']:
8889
if dent.name == filename:
89-
filedata = _process_reg_file(ubifs, inodes[dent.inum], filepath)
90+
filedata = _process_reg_file(ubifs, inodes[dent.inum], filepath, inodes)
9091
if os.path.isdir(destpath):
9192
destpath = os.path.join(destpath, filename)
9293
with open(destpath, 'wb') as f:
@@ -114,8 +115,7 @@ def print_dent(ubifs, inodes, dent_node, long=True, longts=False):
114115

115116
lnk = ""
116117
if dent_node.type == UBIFS_ITYPE_LNK:
117-
lnk = " -> " + inode['ino'].data.decode('utf-8')
118-
118+
lnk = " -> " + decrypt_symlink_target(ubifs, inodes, dent_node)
119119
if longts:
120120
mtime = inode['ino'].mtime_sec
121121
else:
@@ -142,33 +142,47 @@ def file_leng(ubifs, inode):
142142
return fl
143143
return 0
144144

145-
146-
def _process_reg_file(ubifs, inode, path):
145+
def _process_reg_file(ubifs, inode, path, inodes):
147146
try:
148147
buf = bytearray()
148+
start_key = (UBIFS_DATA_KEY << UBIFS_S_KEY_BLOCK_BITS)
149149
if 'data' in inode:
150150
compr_type = 0
151151
sorted_data = sorted(inode['data'], key=lambda x: x.key['khash'])
152-
last_khash = sorted_data[0].key['khash']-1
152+
last_khash = start_key - 1
153153

154154
for data in sorted_data:
155-
156155
# If data nodes are missing in sequence, fill in blanks
157156
# with \x00 * UBIFS_BLOCK_SIZE
158157
if data.key['khash'] - last_khash != 1:
159158
while 1 != (data.key['khash'] - last_khash):
160-
buf += b'\x00'*UBIFS_BLOCK_SIZE
159+
buf += b'\x00' * UBIFS_BLOCK_SIZE
161160
last_khash += 1
162161

163162
compr_type = data.compr_type
164163
ubifs.file.seek(data.offset)
165164
d = ubifs.file.read(data.compr_len)
165+
166+
if ubifs.master_key is not None:
167+
nonce = lookup_inode_nonce(inodes, inode)
168+
block_key = derive_key_from_nonce(ubifs.master_key, nonce)
169+
# block_id is based on the current hash
170+
# there could be empty blocks
171+
block_id = data.key['khash']-start_key
172+
block_iv = struct.pack("<QQ", block_id, 0)
173+
d = datablock_decrypt(block_key, block_iv, d)
174+
# if unpading is needed the plaintext_size is valid and set to the
175+
# original size of current block, so we can use this to get the amout
176+
# of bytes to unpad
177+
d = d[:data.plaintext_size]
178+
166179
buf += decompress(compr_type, data.size, d)
180+
167181
last_khash = data.key['khash']
168182
verbose_log(_process_reg_file, 'ino num: %s, compression: %s, path: %s' % (inode['ino'].key['ino_num'], compr_type, path))
169183

170184
except Exception as e:
171-
error(_process_reg_file, 'Warn', 'inode num:%s :%s' % (inode['ino'].key['ino_num'], e))
185+
error(_process_reg_file, 'Warn', 'inode num:%s path:%s :%s' % (inode['ino'].key['ino_num'], path, e))
172186

173187
# Pad end of file with \x00 if needed.
174188
if inode['ino'].size > len(buf):

0 commit comments

Comments
 (0)