├── .gitignore ├── LICENSE ├── README.md ├── crypto_handler.py ├── mcafee_fde.py ├── mcafuse.py ├── requirements.txt └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | McAFuse - A FUSE utility to handle McAfee FDE in a digital investigation. 4 | 5 | This program expose a static file system containing 2 files: 6 | - SafeBoot.disk: a fat partition inside the encrypted disk 7 | - encdisk.img: an image of the encrypted disk to analyze 8 | 9 | Copyright (c) 2021 Andrea Canepa 10 | Copyright (c) 2021 Francesco Picasso 11 | Copyright (c) 2021 Giovanni Lagorio 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # McAFuse 2 | 3 | McAFuse was developed as a Master's thesis project in Computer Science of *Andrea Canepa* (@A-725-K) in collaboration with *RealityNet (Reality Net System Solutions)*. 4 | 5 | This project aim is to bring to the **DFIR** community an open source utility to handle encrypted disk images built with `McAfee FDE` toolset during digital investigations. It exposes a static **FUSE** *read-only* filesystem containing 2 files: 6 | - ***SafeBoot.disk***: a plain FAT partition present in the encrypted disk 7 | - ***encdisk.img***: the encrypted disk 8 | 9 | ## Getting started 10 | To run this project you have to clone or download this repository, with the command: 11 | ``` 12 | git clone https://github.com/RealityNet/McAFuse 13 | ``` 14 | Then you have to install the `Python3` libraries using the requirements file: 15 | ``` 16 | python3 -m pip install -r requirements.txt 17 | ``` 18 | Finally you can launch `mcafuse.py` with the requested parameters. 19 | 20 | ## How to launch the program 21 | You have to run this command from your terminal, as a *superuser*: 22 | ``` 23 | ./mcafuse.py [-h/--help] [--debug] [-i/--info] [-a/--all] [-v/--verbose] MOUNT_POINT DISK_IMAGE [-k/--keyfile KEY_FILE] 24 | ``` 25 | where the following are mandatory: 26 | - ***MOUNT_POINT***: the new root of the static filesystem you are going to serve 27 | - ***DISK_IMAGE***: the encrypted image of the disk you want to analyze 28 | 29 | and these are optional: 30 | - ***-k/--keyfile KEY_FILE***: the XML file provided by McAfee FDE installation containing a \<**key**\> tag with *base64* encoded password to decrypt the disk 31 | - ***-h/--help***: print the help and quit immediately 32 | - ***-i/--info***: print the disk information gathered from SafeBootDiskInfo 33 | - ***-a/--all***: expose all disk and not only an encrypted volume 34 | - ***-v/--verbose***: to print more information on the execution 35 | - ***--debug***: print debug information 36 | 37 | In case a keyfile is not specified, only *SafeBoot.disk* will be provided to the final user. 38 | 39 | ## Authors 40 | 41 | * ***Andrea Canepa*** - *Computer Science, UNIGE* - DFIR 2021 42 | * ***Francesco Picasso*** - *RealityNet* - DFIR 2021 43 | * ***Giovanni Lagorio*** - *Computer Science Teacher, UNIGE* - DFIR 2021 -------------------------------------------------------------------------------- /crypto_handler.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode 2 | from Crypto.Cipher import AES 3 | from utils import SECTOR_SIZE, pack, log 4 | from defusedxml.ElementTree import parse, ParseError 5 | 6 | 7 | AES_KEY_SIZE = 32 8 | 9 | 10 | # CryptoHandler: this class handle all the cryptographic operations, in particular 11 | # it decrypts a sector during a read operation 12 | # 13 | # Params: @xml_file: path to the McAfee generated file containing the key 14 | # @verbose : boolean flag to determine wether print information during execution 15 | class CryptoHandler: 16 | def __init__(self, xml_file, verbose): 17 | self._AESkey = self._get_key_from_XML(xml_file) 18 | self._sector_iv_cipher = AES.new(self._AESkey, AES.MODE_ECB) 19 | 20 | if verbose: 21 | log.info(f'|++| Sector size: {SECTOR_SIZE}') 22 | log.info(f'|++| AES-256-CBC key instantiated: {self._AESkey}') 23 | 24 | 25 | def _find_sector(self, off): 26 | return off // SECTOR_SIZE 27 | 28 | 29 | def _decrypt_sector(self, source, sector_no): 30 | pre_iv = pack(' SECTOR_SIZE: 83 | data += self._decrypt_sector(source, sector_no) 84 | size -= SECTOR_SIZE 85 | sector_no += 1 86 | 87 | # last (potentially) partial sector 88 | clear_sector = self._decrypt_sector(source, sector_no) 89 | data += clear_sector[:size] 90 | 91 | return data[:size_orig] -------------------------------------------------------------------------------- /mcafee_fde.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import stat 4 | import errno 5 | import pyfuse3 6 | 7 | from utils import read_addr_in_sectors, SECTOR_SIZE, \ 8 | bytes2int, read_sectors, build_GUID, \ 9 | build_keycheck, log, get_partitions_from_mbr, \ 10 | check_signature 11 | 12 | 13 | # McAfeeFde: this class implement the fs operations 14 | # 15 | # Params: @disk_image: encrypted disk image to mirror 16 | # @crypto_hnl: crypto handler object to handle decryption 17 | # @info : boolean flag to determine wether print disk info 18 | # @all_disk : boolean flag to determine wether expose all disk or only a partition 19 | # @verbose : boolean flag to determine wether print information during execution 20 | class McafeeFde(pyfuse3.Operations): 21 | def __init__(self, disk_image, crypto_hnl, info, all_disk, verbose): 22 | super(McafeeFde, self).__init__() 23 | 24 | self._safebootdiskinf_sector = 0 # to print useful info 25 | self._sector_map = dict() # [starting_sector] -> how_many 26 | self._sbfsdisk_data = b'' # safeboot fat partition 27 | self._partition_start = 0 # address in secotrs of the partition considered 28 | self._partition_len = 0 # length of partition in sectors 29 | self._all_disk = all_disk # consider all disk or only a partition 30 | self._verbose = verbose # determine how much information is printed in output 31 | self._crypto_handler = crypto_hnl # if None, expose only SafeBoot.disk 32 | 33 | # backend disk image 34 | self._backend_file_name = disk_image 35 | self._backend_file = open(self._backend_file_name, 'rb') 36 | 37 | # encrypted disk 38 | self.encdisk_name = b'encdisk.img' 39 | self.encdisk_inode = pyfuse3.ROOT_INODE + 1 40 | 41 | # safebootdisk 42 | self.sbfsdisk_name = b'SafeBoot.disk' 43 | self.sbfsdisk_inode = pyfuse3.ROOT_INODE + 2 44 | 45 | # initialize values 46 | self._init_sector_map() 47 | if not self._all_disk: 48 | self._init_encrypted_partition() 49 | if info or self._verbose: 50 | self._print_disk_info() 51 | 52 | 53 | # adjust the offset for read operations selecting the encrypted partition 54 | def _init_encrypted_partition(self): 55 | self._backend_file.seek(0) 56 | first_sector = self._backend_file.read(SECTOR_SIZE) 57 | mbr = first_sector[0x1be:0x1fe] 58 | partitions = get_partitions_from_mbr(mbr) 59 | 60 | if self._verbose: 61 | log.info('\n') 62 | for part in partitions: 63 | part.pretty_print() 64 | 65 | # if there is only 1 partition, it is encrypted 66 | # if there are 2 partitions, usually the 2nd is the encrypted one 67 | how_many_partitions = len(partitions) 68 | if how_many_partitions == 1: 69 | self._partition_start = partitions[0].starting_sector 70 | self._partition_len = partitions[0].total_sectors 71 | if self._verbose: 72 | log.info(f'|++| Chosen the 1st partition.\n' 73 | f'Starting address in sectors: {self._partition_start}\n' 74 | f'\tLength in sectors: {self._partition_len}') 75 | elif how_many_partitions == 2: 76 | self._partition_start = partitions[1].starting_sector 77 | self._partition_len = partitions[1].total_sectors 78 | if self._verbose: 79 | log.info(f'|++| Chosen the 2nd partition.\n' 80 | f'\tStarting address in sectors: {self._partition_start}\n' 81 | f'\tLength in sectors: {self._partition_len}\n') 82 | else: 83 | # TODO: handle more than 2 partitions 84 | raise NotImplemented('This program currenlty does not support disks with more than 2 partitions') 85 | 86 | 87 | # print disk information found in the SafeBoot disk 88 | def _print_disk_info(self): 89 | self._backend_file.seek(self._safebootdiskinf_sector * SECTOR_SIZE) 90 | disk_info = self._backend_file.read(0x5a) 91 | log.info('\n') 92 | print('//\t|+| SafeBoot Disk Info |+|\n|') 93 | print(f'|----- Signature: {disk_info[:0x10].decode()}') 94 | print(f'|------- Disk ID: {disk_info[0x11]}') 95 | print(f'|----- Disk GUID: {build_GUID(disk_info)}') 96 | print(f'|----- Algorithm: {hex(disk_info[0x37])} (AES-256-CBC)') 97 | print(f'|---- Sector Map: {bytes2int(disk_info[0x43:0x47])}') 98 | print(f'|-- Sector Count: {disk_info[0x4b]}') 99 | print(f'|----- Key Check: {build_keycheck(disk_info)}') 100 | print(f'|\n\\\\\t|+| ****************** |+|') 101 | 102 | 103 | # read from the sector map and rebuild Safe Boot partition 104 | def _read_sector_map(self): 105 | for base, how_many in self._sector_map.items(): 106 | self._sbfsdisk_data += read_sectors(self._backend_file, base, how_many) 107 | 108 | 109 | # read from disk the sector map [starting_sector]=how_many_sectors 110 | def _init_sector_map(self): 111 | self._backend_file.seek(0x1c) # 0x1c ==> address in sectors of SafeBootDiskInf 112 | self._safebootdiskinf_sector = read_addr_in_sectors(self._backend_file) 113 | 114 | check_signature(self._backend_file_name, b'SafeBootDiskInf', self._safebootdiskinf_sector*SECTOR_SIZE) 115 | 116 | self._backend_file.seek(self._safebootdiskinf_sector*SECTOR_SIZE + 0x43) # 0x43 ==> start of sector map 117 | sectormap_start = read_addr_in_sectors(self._backend_file) 118 | self._backend_file.seek(sectormap_start*SECTOR_SIZE + 0x4) 119 | 120 | entry_no = 0 121 | line = self._backend_file.read(0x10) 122 | first = True 123 | while bytes2int(line[:0x4]) != 0: 124 | starting_sector = bytes2int(line[:0x4]) 125 | how_many = bytes2int(line[0x8:0xc]) 126 | 127 | # ignore first sector containing only a SafeBoot signature 128 | if first: 129 | starting_sector += 1 130 | how_many -= 1 131 | first = False 132 | 133 | self._sector_map[starting_sector] = how_many 134 | line = self._backend_file.read(0x10) 135 | 136 | self._read_sector_map() 137 | 138 | 139 | async def getattr(self, inode, ctx=None): 140 | entry = pyfuse3.EntryAttributes() 141 | 142 | if inode == pyfuse3.ROOT_INODE: 143 | entry.st_mode = (stat.S_IFDIR | 0o555) # read-only 144 | entry.st_size = 0 145 | elif inode == self.encdisk_inode: 146 | entry.st_mode = (stat.S_IFREG | 0o444) # read-only 147 | if self._all_disk: # all disk 148 | entry.st_size = os.stat(self._backend_file_name).st_size 149 | else: # only a volume 150 | entry.st_size = (self._partition_start + self._partition_len) * SECTOR_SIZE 151 | elif inode == self.sbfsdisk_inode: 152 | entry.st_mode = (stat.S_IFREG | 0o444) # read-only 153 | entry.st_size = len(self._sbfsdisk_data) 154 | else: 155 | raise pyfuse3.FUSEError(errno.ENOENT) 156 | 157 | # set a fake timestamp in files information 158 | timestamp = 824463 * 1e12 159 | entry.st_atime_ns = timestamp 160 | entry.st_ctime_ns = timestamp 161 | entry.st_mtime_ns = timestamp 162 | entry.st_gid = os.getgid() 163 | entry.st_uid = os.getuid() 164 | entry.st_ino = inode 165 | 166 | return entry 167 | 168 | 169 | async def lookup(self, parent_inode, name, ctx=None): 170 | if parent_inode != pyfuse3.ROOT_INODE or \ 171 | name != self.encdisk_name or \ 172 | name != self.sbfsdisk_name: 173 | raise pyfuse3.FUSEError(errno.ENOENT) 174 | 175 | if name == self.encdisk_name: 176 | return self.getattr(self.encdisk_inode) 177 | 178 | return self.getattr(self.sbfsdisk_inode) 179 | 180 | 181 | async def opendir(self, inode, ctx): 182 | if inode != pyfuse3.ROOT_INODE: 183 | raise pyfuse3.FUSEError(errno.ENOENT) 184 | 185 | return inode 186 | 187 | 188 | async def readdir(self, fh, start_id, token): 189 | assert fh == pyfuse3.ROOT_INODE 190 | 191 | if start_id == 0: 192 | # safeboot 193 | pyfuse3.readdir_reply( 194 | token, 195 | self.sbfsdisk_name, 196 | await self.getattr(self.sbfsdisk_inode), 197 | 1 198 | ) 199 | # encrypted disk 200 | if self._crypto_handler is not None: 201 | pyfuse3.readdir_reply( 202 | token, 203 | self.encdisk_name, 204 | await self.getattr(self.encdisk_inode), 205 | 1 206 | ) 207 | 208 | 209 | async def open(self, inode, flags, ctx): 210 | if inode != self.encdisk_inode and inode != self.sbfsdisk_inode: 211 | raise pyfuse3.FUSEError(errno.ENOENT) 212 | 213 | # read-only 214 | if flags & os.O_RDWR or flags & os.O_WRONLY: 215 | raise pyfuse3.FUSEError(errno.EACCES) 216 | 217 | return pyfuse3.FileInfo(fh=inode) 218 | 219 | 220 | async def read(self, fh, off, size): 221 | if self._verbose: 222 | log.info(f'read: fh={fh}\toffset={off}\tn_bytes={size}') 223 | 224 | if fh != self.encdisk_inode and fh != self.sbfsdisk_inode: 225 | raise pyfuse3.FUSEError(errno.ENOENT) 226 | 227 | # read SafeBoot.disk 228 | if fh == self.sbfsdisk_inode: 229 | return self._sbfsdisk_data[off:off+size] 230 | 231 | # read encdisk.img 232 | return await self._crypto_handler.decrypt_at_offset( 233 | self._backend_file, 234 | self._partition_start*SECTOR_SIZE + off, 235 | size 236 | ) -------------------------------------------------------------------------------- /mcafuse.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import trio 5 | import pyfuse3 6 | 7 | from mcafee_fde import McafeeFde 8 | from crypto_handler import CryptoHandler 9 | from utils import parse_args, init_logging, log, check_signature, check_if_files_exist 10 | 11 | 12 | def main(): 13 | options = parse_args() 14 | init_logging(options.debug) 15 | 16 | if options.verbose: 17 | log.info('|++| Starting McAfuse...') 18 | 19 | crypto_hnl = None 20 | mcafee_fde = None 21 | try: 22 | check_if_files_exist(options.disk_image, options.keyfile, options.mountpoint) 23 | check_signature(options.disk_image, b'#SafeBoot', 0x2) 24 | if options.keyfile: 25 | crypto_hnl = CryptoHandler(options.keyfile, options.verbose) 26 | mcafee_fde = McafeeFde( 27 | options.disk_image, 28 | crypto_hnl, 29 | options.info, 30 | options.all, 31 | options.verbose 32 | ) 33 | except (ValueError, FileNotFoundError, NotADirectoryError, NotImplementedError) as ex: 34 | log.error(ex) 35 | return 36 | 37 | fuse_options = set(pyfuse3.default_options) 38 | fuse_options.add('fsname=McAFuse') 39 | fuse_options.add('allow_other') # to allow user to navigate fuse fs 40 | 41 | try: 42 | pyfuse3.init(mcafee_fde, options.mountpoint, fuse_options) 43 | trio.run(pyfuse3.main) 44 | except RuntimeError as re: 45 | log.error(re) 46 | log.error('|!| -- [ERROR] -- The program need administrative privileges, try with sudo') 47 | return 48 | except: 49 | log.warning('|--| Terminated with CTRL-C (SIGINT)...') 50 | log.warning('|--| Unmounting...') 51 | pyfuse3.close(unmount=True) 52 | return 53 | 54 | if options.verbose: 55 | log.info('|++| Gracefully terminating McAfuse...') 56 | 57 | pyfuse3.close() 58 | 59 | 60 | if __name__ == '__main__': 61 | main() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyfuse3==3.1.1 2 | trio==0.17.0 3 | defusedxml==0.6.0 4 | pycryptodome==3.9.9 -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import logging 3 | 4 | from struct import pack, unpack 5 | from argparse import ArgumentParser 6 | 7 | try: 8 | import faulthandler 9 | except ImportError: 10 | pass 11 | else: 12 | faulthandler.enable() 13 | 14 | 15 | SECTOR_SIZE = 512 # bytes 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | # simple struct the gather only useful info from MBR structure 21 | class MBREntry: 22 | def __init__(self, entry, index): 23 | self.index = index 24 | self.status = entry[0x0] 25 | self.type = entry[0x4] 26 | self.starting_sector = bytes2int(entry[0x8:0xc]) 27 | self.total_sectors = bytes2int(entry[0xc:]) 28 | 29 | 30 | def pretty_print(self): 31 | print(f'[*** MBR entry {self.index} ***]') 32 | print(f'|-- Status: {"{:02x}".format(self.status)}') 33 | print(f'|-- Type: {"{:02x}".format(self.type)}') 34 | print(f'|-- Start: {self.starting_sector}') 35 | print(f'|-- Count: {self.total_sectors}\n') 36 | 37 | 38 | # retrieve information of partitions from MBR 39 | def get_partitions_from_mbr(mbr): 40 | partitions = [] 41 | for i in range(4): 42 | entry = mbr[i*0x10 : i*0x10+0x10] 43 | if entry == b'\x00'*0x10: # skip empty entries 44 | continue 45 | partitions.append(MBREntry(entry, i)) 46 | return partitions 47 | 48 | 49 | # rebuild disk GUID starting from sector SafeBootDiskInfo 50 | def build_GUID(disk_info): 51 | guid = [] 52 | 53 | n = '' 54 | for i in range(0x2a, 0x26, -1): 55 | n += '{:02x}'.format(disk_info[i]) 56 | guid.append(n) 57 | 58 | guid.append('{:02x}'.format(disk_info[0x2c]) + '{:02x}'.format(disk_info[0x2b])) 59 | guid.append('{:02x}'.format(disk_info[0x2e]) + '{:02x}'.format(disk_info[0x2d])) 60 | for i in range(0x2f, 0x37): 61 | guid.append('{:02x}'.format(disk_info[i])) 62 | 63 | # GUID format: XXXX-XX-XX-X-X-X-X-X-X-X-X, X == bytes in range [0x00, 0xff] 64 | return '-'.join(map(lambda x: str(x), guid)).upper() 65 | 66 | 67 | # rebuild key check value (8 bytes value) starting from sector SafeBootDiskInfo 68 | def build_keycheck(disk_info): 69 | return ''.join(['{:02x}'.format(b) for b in disk_info[0x4d:0x55][::-1]]).upper() 70 | 71 | 72 | # read multiple contiguous sectors 73 | def read_sectors(source, base, how_many): 74 | source.seek(base * SECTOR_SIZE) 75 | return source.read(how_many * SECTOR_SIZE) 76 | 77 | 78 | # convert 4 bytes (32 bits) to an integer value 79 | def bytes2int(b): 80 | return int(unpack('