├── README.md ├── LICENSE ├── .gitignore └── qnx6_extractor └── main.py /README.md: -------------------------------------------------------------------------------- 1 | # qnx6-extractor 2 | Python-based extractor for QNX6 filesystem format. 3 | 4 | ### Credit 5 | This was originally authored by Mathew Evans (mathew.evans@nop.ninja) and released under MIT license with his express permission. 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ReFirm Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /qnx6_extractor/main.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | ############################# 3 | ## 4 | # QNX6FS Partition Parser and Automatic file extraction 5 | ## ----------------------------------------------------- 6 | # 7 | ## Author: Mathew Evans (mathew.evans@nop.ninja) 8 | # Revision: 0.2d rev2 (release-candidate) / Dec 2019 9 | ## 10 | # updates posted @ https://www.forensicfocus.com/Forums/viewtopic/t=16846/ 11 | ## 12 | #################### 13 | #!/usr/bin/python 14 | # -*- coding: utf-8 -*- 15 | 16 | import binascii, math, zlib, sys, re, os, errno 17 | from struct import * 18 | from collections import OrderedDict 19 | 20 | class QNX6FS: 21 | 22 | PARTITION_MAGIC = {'QNX4':0x002f,'QNX6':0x68191122} 23 | FILE_TYPE = {'DIRECTORY':0x01,'DELETED':0x02,'FILE':0x03} 24 | 25 | QNX6_SUPERBLOCK_SIZE = 0x200 #Superblock is fixed (512 bytes) 26 | QNX6_SUPERBLOCK_AREA = 0x1000 #Area reserved for superblock 27 | QNX6_BOOTBLOCK_SIZE = 0x2000 #Boot Block Size 28 | QNX6_DIR_ENTRY_SIZE = 0x20 #Dir block size (32 bytes) 29 | QNX6_INODE_SIZE = 0x80 #INode block size (128 bytes) 30 | QNX6_INODE_SIZE_BITS = 0x07 #INode entry size shift 31 | 32 | QNX6_NO_DIRECT_POINTERS = 16 #Max Direct iNodes 33 | QNX6_PTR_MAX_LEVELS = 5 #Max Indirect iNodes 34 | QNX6_SHORT_NAME_MAX = 27 #Short Name Max Length 35 | QNX6_LONG_NAME_MAX = 510 #Long Name Max Length 36 | 37 | def __init__(self, source): 38 | self.TARGET_ = source 39 | 40 | def GetPartitions(self): 41 | with open(self.TARGET_, "rb") as handle: 42 | DataBlock = handle.read(512); 43 | 44 | ##Split DataBlock into parts 45 | BootCode = DataBlock[0:446] 46 | MasterPartitionTable = DataBlock[446:510] 47 | BootRecordSignature = DataBlock[510:512] 48 | 49 | ##Detect if MBR is valid. 50 | BootRecordSignature = unpack('H', BootRecordSignature)[0] 51 | if BootRecordSignature == 0xAA55: 52 | if ord(BootCode[0]) == 235: 53 | return self.parseNoPartitionQNX(handle,0) 54 | else: 55 | print "[-] BootRecordSignature Detected." 56 | return self.parsePartitionMBR(handle,0) 57 | else: 58 | raise IOError('[ERROR] BootRecordSignature Missing; Invalid Disk Image') 59 | exit() 60 | return null 61 | 62 | def parsePartitionMBR(self, fileIO, offset, blockSize=512): 63 | fileIO.seek(offset,0); #absolute from start of file 64 | DataBlock = fileIO.read(blockSize) 65 | PartitionTable = DataBlock[446:510] 66 | PartitionList={} 67 | for i in range(0,4): 68 | Offset= 0 + (i * 16); 69 | PartitionList[i]={} 70 | PartitionList[i]['BootIndicator'] = PartitionTable[Offset+0] 71 | PartitionList[i]['StartingCHS'] = PartitionTable[Offset+1:Offset+4] 72 | PartitionList[i]['PartitionType'] = PartitionTable[Offset+4] 73 | PartitionList[i]['EndingCHS'] = PartitionTable[Offset+5:8] 74 | PartitionList[i]['StartingSector'] = unpack('I', sb[4:8])[0]) 180 | SB['checksum_calc'] = zlib.crc32(sb[8:512],0) & 0xFFFFFFFF 181 | SB['serial'] = unpack(' self.QNX6_PTR_MAX_LEVELS): 220 | print "[x] invalid Inode structure." 221 | return 0 222 | #print " |--+PTR: " 223 | for n in range(0, 16): 224 | ptr = superBlock['Inode']['ptr'][n] 225 | if self.checkQNX6blkptr(ptr): 226 | ptr_ = (ptr*superBlock['blocksize'])+superBlock['blks_offset']; 227 | #print " |--",n," : ",format(ptr_,'02x') 228 | superBlock['Inodes'] = self.praseQNX6Inode(ptr,superBlock['Inode']['level'],superBlock['blocksize'],superBlock['blks_offset']) 229 | 230 | print "[-] Generating directory Listing && Auto Extracting Files to (.\\Extracted\\Partition"+str(PartitionID)+")" 231 | self.parseINodeDIRStruct(superBlock['blocksize'],superBlock['blks_offset']) 232 | 233 | for i in self.DirTree: 234 | self.dumpfile(i,'.\\Extraced\\',superBlock['blocksize'],superBlock['blks_offset'],PartitionID) 235 | 236 | #self.parseINodeDIRbyID(1,superBlock['blocksize'],superBlock['blks_offset']) 237 | 238 | def parseLongFileNames(self,superBlock): 239 | print " |--+ Longfile: Detected - Processing...." 240 | #print " |---Size:", superBlock['Longfile']['size'] 241 | #print " |---Level:", superBlock['Longfile']['level'] 242 | #print " |---Mode:", superBlock['Longfile']['mode'] 243 | if (superBlock['Longfile']['level'] > self.QNX6_PTR_MAX_LEVELS): 244 | print " *invalid levels, too many*" 245 | #print " |---PTR: " 246 | longnames = [] 247 | for n in range(0, 16): 248 | ptr = superBlock['Longfile']['ptr'][n] 249 | if self.checkQNX6blkptr(ptr): 250 | ptrB = (ptr*superBlock['blocksize'])+superBlock['blks_offset']; 251 | #print " |--",n,":",format(ptrB,'02x') 252 | longnames.append(self.parseQNX6LongFilename(ptr,superBlock['Longfile']['level'],superBlock['blocksize'],superBlock['blks_offset'])) 253 | 254 | ##Make Dictionary with all Names and INode/PTRs 255 | count = 1 256 | Dict = {} 257 | for i in longnames: 258 | if i != None: 259 | for q in i: 260 | if q != None: 261 | Dict[count] = i[q] 262 | count = count + 1; 263 | return Dict 264 | 265 | def parseQNX6LongFilename(self,ptr_,level,blksize,blksOffset): 266 | self.fileIO.seek((ptr_*blksize)+blksOffset) 267 | handle = self.fileIO.read(512) 268 | LogFilenameNode={} 269 | if level == 0: 270 | size = unpack(' 0: 273 | LogFilenameNode[str(ptr_)] = str("".join("%c" % i for i in fname )).strip() 274 | return LogFilenameNode 275 | else: 276 | return None 277 | else: 278 | Pointers = unpack('<128I', handle) 279 | for i in range(0, 128): 280 | if (self.checkQNX6blkptr(Pointers[i]) != False): 281 | name = (self.parseQNX6LongFilename(Pointers[i],level-1,blksize,blksOffset)) 282 | if name != None: 283 | if level >= 1: 284 | LogFilenameNode[str(Pointers[i])]=name[str(Pointers[i])] 285 | else: 286 | LogFilenameNode[str(Pointers[i])]=name 287 | return LogFilenameNode 288 | 289 | def praseQNX6Inode(self,ptr,level,blksize,blksOffset): 290 | ptr_=(ptr*blksize)+blksOffset 291 | if self.checkQNX6blkptr(ptr_) and ptr != 0xffffffff: 292 | self.fileIO.seek(ptr_) 293 | RawData = self.fileIO.read(blksize) 294 | 295 | if level >= 1: 296 | Pointers = unpack('<'+str(blksize/4)+'I', RawData) 297 | for i in range(0, (blksize/4)): 298 | if self.checkQNX6blkptr((Pointers[i]*blksize)+blksOffset): 299 | self.praseQNX6Inode(Pointers[i],level-1,blksize,blksOffset) 300 | else: 301 | inode_range = (blksize / 128) 302 | for i in range(0,inode_range): 303 | try: 304 | item = self.parseQNX6InodeEntry(RawData[(i*(blksize/inode_range)):((i+1)*(blksize/inode_range))]) 305 | self.INodeTree[len(self.INodeTree)+1] = item 306 | except: 307 | print i, len(self.INodeTree), format(ptr_,'02x'), format(ptr,'02x') 308 | self.INodeTree[len(self.INodeTree)+1] = None 309 | break 310 | 311 | def parseQNX6InodeEntry(self, ie): #qnx6_inode_entry 128bytes 312 | IE = {} 313 | IE['size'] = unpack(' 0: 345 | objects = self.parseInodeDirBatch(PhysicalPTRs,blksize,blksOffset) 346 | 347 | ##find perant INode ID (. and .. will be same at root == 1) 348 | rootID = 0 349 | for i in objects: 350 | if objects[i]['Name'] == ".": 351 | rootID=objects[i]['PTR'] 352 | break; 353 | 354 | for i in objects: 355 | obj = objects[i] 356 | if obj['Name'] != ".." and obj['Name'] != ".": 357 | self.DirTree[ obj['PTR'] ] = {'Name':obj['Name'],'ROOT_INODE':rootID} 358 | 359 | ##Recursively Process all Dirs 360 | if obj['PTR'] > 1: 361 | self.parseINodeDIRStruct(blksize,blksOffset,obj['PTR']) 362 | 363 | def parseInodeDirBatch(self,ptrs,blksize,blksOffset): 364 | DIR = {} 365 | for ptr in ptrs: 366 | self.fileIO.seek(ptr) 367 | RawData = self.fileIO.read(blksize) 368 | for i in range(0,(blksize/32)): 369 | raw = RawData[ i*32: ((i+1)*32) ] 370 | if (unpack('I', raw[12:16])[0] 379 | DIR[str(ptr)+"-"+str(i)]['Name'] = self.LongNames[unpack('>I', raw[5:9])[0]+1] #self.LongNames[unpack(' 0) and (DIRS[idir]['Length'] < 28): 395 | if DIRS[idir]['Name'] != "." and DIRS[idir]['Name'] != "..": 396 | print (" "*level),"+-",DIRS[idir]['Name'] #, " -- " , DIRS[idir]['PTR'] 397 | 398 | elif DIRS[idir]['Name'] == "..": 399 | root = DIRS[idir]['PTR']; 400 | 401 | if DIRS[idir]['PTR'] != INodeID and DIRS[idir]['Name'] != "." and DIRS[idir]['Name'] != ".." and DIRS[idir]['PTR'] > 2 : 402 | self.parseINodeDIRbyID(DIRS[idir]['PTR'],blksize,blksOffset,level+1) 403 | 404 | def parseInodeDir(self,ptr,blksize,blksOffset): 405 | self.fileIO.seek(ptr) 406 | RawData = self.fileIO.read(blksize) 407 | DIR = {} 408 | for i in range(0,(blksize/32)): 409 | raw = RawData[ i*32: ((i+1)*32) ] 410 | DIR[i]={} 411 | DIR[i]['PTR'] = unpack(' self.QNX6_PTR_MAX_LEVELS): 437 | print " *invalid levels, too many*" 438 | #print " |--+PTR: " 439 | for n in range(0, 16): 440 | ptr = superBlock['Bitmap']['ptr'][n] 441 | if self.checkQNX6blkptr(ptr): 442 | ptr_ = (ptr*superBlock['blocksize'])+superBlock['blks_offset']; 443 | #print " |--",n," : ",format(ptr_,'02x') 444 | self.praseQNX6Bitmap(ptr,superBlock['Bitmap']['level'],superBlock['blocksize'],superBlock['blks_offset']) 445 | 446 | #if len(self.Bitmaps) > 0: 447 | # for i in range(1,len(self.Bitmaps)): 448 | # print format(self.Bitmaps[i]['PTR'],'02x') 449 | dcount = 0; 450 | count = 0; 451 | if len(self.Bitmaps) > 0: 452 | count = 0; 453 | for i in range(1,len(self.Bitmaps)): 454 | Data = self.Bitmaps[i]['DATA'] 455 | for byte in Data: 456 | if ord(byte) > 0: 457 | for ii in range(0,7): 458 | bit = ((ord(byte) >> ii) & 00000001 ) 459 | #print bit, 460 | #sys.stdout.write(str(bit)) 461 | #sys.stdout.flush() 462 | if bit == 0: 463 | if self.isBlockEmpty(count,superBlock['blocksize'],superBlock['blks_offset']) == False: 464 | dcount = dcount + 1; 465 | PhysicalPTR=((count)*superBlock['blocksize'])+superBlock['blks_offset'] 466 | snip=self.getSnippet(count,superBlock['blocksize'],superBlock['blks_offset']) 467 | #print " |---Deleted Data @:", format(PhysicalPTR,'02x') , "(",snip,")" 468 | count = count + 1; 469 | print " |---Deleted Blocks:", dcount , "found" 470 | 471 | def praseQNX6Bitmap(self,ptr,level,blksize,blksOffset): 472 | ptr_=(ptr*blksize)+blksOffset 473 | if self.checkQNX6blkptr(ptr_) and ptr != 0xffffffff: 474 | self.fileIO.seek(ptr_) 475 | RawData = self.fileIO.read(blksize) 476 | 477 | if level >= 1: 478 | Pointers = unpack('<'+str(blksize/4)+'I', RawData) 479 | for i in range(0, (blksize/4)): 480 | if self.checkQNX6blkptr((Pointers[i]*blksize)+blksOffset) and Pointers[i] != 0xffffffff and Pointers[i] != 0x0: 481 | #print (Pointers[i]*blksize)+blksOffset 482 | self.praseQNX6Bitmap(Pointers[i],level-1,blksize,blksOffset) 483 | else: 484 | self.Bitmaps[len(self.Bitmaps)+1] = {'PTR':ptr_, 'DATA':RawData} 485 | 486 | def isBlockEmpty(self,blocknumber,blksize,blksOffset): 487 | PhysicalPTR=(blocknumber*blksize)+blksOffset 488 | self.fileIO.seek(PhysicalPTR) 489 | Data = self.fileIO.read(blksize) #.replace("\x00","") 490 | Data = ''.join([x for x in Data if ord(x) > 0]) 491 | return len(Data) < 1 492 | 493 | def getSnippet(self,blocknumber,blksize,blksOffset): 494 | PhysicalPTR=(blocknumber*blksize)+blksOffset 495 | self.fileIO.seek(PhysicalPTR) 496 | Data = self.fileIO.read(40) #.replace("\x00","") 497 | Data = re.sub(r'[^\x00-\x7F]+','', Data) 498 | Data = ''.join([x for x in Data if ord(x) < 128]) 499 | Data = ''.join([x for x in Data if ord(x) > 20]) 500 | return Data.strip().rstrip() 501 | 502 | def parseQNX6LongDirEntry(self,dn): 503 | DN={} 504 | DN['inode']= unpack('= 1024: 560 | DATABUFF += self.fileIO.read(blksize) 561 | else: 562 | DATABUFF += self.fileIO.read((InodeDataEntry['size'] - io.tell())) 563 | else: 564 | self.fileIO.seek(ptrs[i]) 565 | newPTRS = unpack('<'+str(blksize/4)+'I', self.fileIO.read(blksize)) 566 | level2_PTRS = [] 567 | for i in range(0,len(newPTRS)): 568 | if self.checkQNX6blkptr(newPTRS[i]): 569 | if newPTRS[i] != 0xffffffff and newPTRS[i] != 0x0: 570 | level2_PTRS += [(newPTRS[i]*blksize)+blkOffset] 571 | self.batchProcessPTRS(level2_PTRS,InodeDataEntry,level-1,blksize,blkOffset,path,io) 572 | 573 | if level == 0: 574 | io.write(DATABUFF) 575 | 576 | def bytes2human(self,n, format='%(value).1f %(symbol)s'): 577 | n = int(n) 578 | if n < 0: raise ValueError("n < 0") 579 | symbols = ('B','KB','MB','GB','TB','PB','EB','ZB','IB') 580 | prefix = {} 581 | for i, s in enumerate(symbols[1:]): 582 | prefix[s] = 1 << (i+1)*10 583 | for symbol in reversed(symbols[1:]): 584 | if n >= prefix[symbol]: 585 | value = float(n) / prefix[symbol] 586 | return format % locals() 587 | return format % dict(symbol=symbols[0], value=n) 588 | 589 | def main(): 590 | if os.path.exists(sys.argv[1]): 591 | Q6 = QNX6FS(sys.argv[1]); 592 | Partitions = Q6.GetPartitions(); 593 | print "[-] Detected", len(Partitions) ,"Partitions." 594 | 595 | for i in range(0, len(Partitions)): 596 | if ( Partitions[i]['qnx6'] == True ): 597 | print "[-] Processing Partition",i+1,"of",len(Partitions) 598 | Q6.ParseQNX(Partitions[i],i+1) 599 | print "" 600 | break 601 | else: 602 | print "QNX6FS Parser v0.2d rev 2; mathew.evans[@]nop[.]ninja ; Dec 2019" 603 | print "------------------------------------------------------------" 604 | print "- src/url: https://NOP.ninja/qnx6-0.2d.py" 605 | print "- " 606 | print "- THIS IS A RELESE-CANDIDATE; USE AT YOUR OWN RISK " 607 | print "- " 608 | print "- [+] QNX 6.5.0 DONE" 609 | print "- [-] QNX 6.5.1 TDB" 610 | print "- " 611 | print "- Usage: qnx6.py RAWIMAGEFILE.001" 612 | print "------------------------------------------------------------" 613 | 614 | if __name__ == "__main__": 615 | main(); 616 | --------------------------------------------------------------------------------