Last active
January 14, 2021 22:17
-
-
Save jaames/3cf757bbbc2de664e1e33955a7a1930b to your computer and use it in GitHub Desktop.
new php PPM metadata parser
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Formats; | |
class PPMParser { | |
protected $data = null; | |
protected $offset = 0; | |
public $header = []; | |
public $meta = null; | |
public $animationHeader = null; | |
public $soundHeader = null; | |
// This key can only be used to verify PPM signatures, not sign them | |
private $pubkey = <<<EOT | |
-----BEGIN PUBLIC KEY----- | |
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDCPLwTL6oSflv+gjywi/sM0TUB | |
90xqOvuCpjduETjPoN2FwMebxNjdKIqHUyDu4AvrQ6BDJc6gKUbZ1E27BGZoCPH4 | |
9zQRb+zAM6M9EjHwQ6BABr0u2TcF7xGg2uQ9MBWz9AfbVQ91NjfrNWo0f7UPmffv | |
1VvixmTk1BCtavZxBwIDAQAB | |
-----END PUBLIC KEY----- | |
EOT; | |
private function read($nbytes = 1) | |
{ | |
$ret = substr($this->data, $this->offset, $nbytes); | |
$this->offset += $nbytes; | |
return $ret; | |
} | |
private function seek($offset, $whence = 0) | |
{ | |
switch ($whence) { | |
case 2: | |
$this->offset = strlen($this->$data) + $offset; | |
break; | |
case 1: | |
$this->offset += $offset; | |
break; | |
case 0: | |
default: | |
$this->offset = $offset; | |
break; | |
} | |
} | |
private function unpack($spec, $nbytes = 0) | |
{ | |
$formatString = []; | |
foreach ($spec as $var => $format) | |
{ | |
array_push($formatString, $format . $var); | |
} | |
return unpack(join('/', $formatString), $this->read($nbytes)); | |
} | |
private function formatFilename($filename) | |
{ | |
$f = unpack('H6MAC/a13random/vedits', $filename); | |
return strtoupper(sprintf('%6s_%13s_%03d', $f['MAC'], $f['random'], $f['edits'])); | |
} | |
private function formatUsername($username){ | |
return trim(mb_convert_encoding($username, 'UTF-8', 'UTF-16LE')); | |
} | |
private function formatFSID($id){ | |
return sprintf('%016X', $id); | |
} | |
public function __construct($data) | |
{ | |
$this->offset = 0; | |
$this->data = $data; | |
$this->meta = null; | |
$this->animationHeader = null; | |
$this->soundHeader = null; | |
$this->header = $this->unpack([ | |
'magic' => 'a4', | |
'frameDataLength' => 'V', | |
'soundDataLength' => 'V', | |
'frameCount' => 'v', | |
// assuming this is version | |
'version' => 'v' | |
], 16); | |
// frame count starts at 0 = 1, so we need to correct this | |
$this->header['frameCount'] += 1; | |
$soundDataOffset = 0x06A0 + $this->header['frameDataLength'] + $this->header['frameCount']; | |
// account for padding | |
if ($soundDataOffset % 4 !== 0) $soundDataOffset += 4 - ($soundDataOffset % 4); | |
$this->header['soundDataOffset'] = $soundDataOffset; | |
} | |
public function getMeta() | |
{ | |
// if meta is already parsed | |
if (isset($this->meta)) | |
{ | |
return $this->meta; | |
} | |
$this->seek(16); | |
$rawMeta = $this->unpack([ | |
'lock' => 'v', | |
'thumbIndex' => 'v', | |
'rootAuthorName' => 'a22', | |
'parentAuthorName' => 'a22', | |
'currentAuthorName' => 'a22', | |
'parentAuthorID' => 'P', | |
'currentAuthorID' => 'P', | |
'parentFilename' => 'a18', | |
'currentFilename' => 'a18', | |
'rootAuthorID' => 'P', | |
// skip some stuff, jump to timestamp offset | |
'@138' => '', | |
'timestamp' => 'V', | |
// skip thumbnail, jump to animation header flags | |
'@1686' => '', | |
'flags' => 'v' | |
], 1688); | |
// unpack sound header too | |
$soundHeader = $this->getSoundHeader(); | |
// return pretty-printed meta | |
$this->meta = [ | |
'lock' => $rawMeta['lock'], | |
'loop' => $rawMeta['flags'] >> 1 & 0x01, | |
'frameCount' => $this->header['frameCount'], | |
'frameSpeed' => $soundHeader['frameSpeed'], | |
'thumbIndex' => $rawMeta['thumbIndex'], | |
'timestamp' => $rawMeta['timestamp'] + 946684800, | |
'root' => [ | |
'username' => $this->formatUsername($rawMeta['rootAuthorName']), | |
'fsid' => $this->formatFSID($rawMeta['rootAuthorID']) | |
], | |
'parent' => [ | |
'username' => $this->formatUsername($rawMeta['parentAuthorName']), | |
'fsid' => $this->formatFSID($rawMeta['parentAuthorID']), | |
'filename' => $this->formatFilename($rawMeta['parentFilename']) | |
], | |
'current' => [ | |
'username' => $this->formatUsername($rawMeta['currentAuthorName']), | |
'fsid' => $this->formatFSID($rawMeta['currentAuthorID']), | |
'filename' => $this->formatFilename($rawMeta['currentFilename']) | |
], | |
'trackUsage' => [ | |
'BGM' => $soundHeader["BGMLength"] > 0, | |
'SE1' => $soundHeader["SE1Length"] > 0, | |
'SE2' => $soundHeader["SE2Length"] > 0, | |
'SE3' => $soundHeader["SE3Length"] > 0, | |
], | |
'trackFrameSpeed' => $soundHeader['BGMSpeed'] | |
]; | |
return $this->meta; | |
} | |
public function getAnimationHeader() | |
{ | |
// if animation header is already parsed | |
if (isset($this->animationHeader)) | |
{ | |
return $this->animationHeader; | |
} | |
$this->seek(0x06A0); | |
$this->animationHeader = $this->unpack([ | |
'tableSize' => 'v', | |
'baseOffset' => 'v', | |
'flags' => 'V', | |
], 8); | |
$numEntries = min(max($this->animationHeader['tableSize'] / 4, 0), 999); | |
$this->animationHeader['table'] = unpack('V*', $this->read($numEntries * 4)); | |
return $this->animationHeader; | |
} | |
public function getSoundHeader() | |
{ | |
$this->seek($this->header['soundDataOffset']); | |
$this->soundHeader = $this->unpack([ | |
'BGMLength' => 'V', | |
'SE1Length' => 'V', | |
'SE2Length' => 'V', | |
'SE3Length' => 'V', | |
'frameSpeed' => 'c', | |
'BGMSpeed' => 'c', | |
], 18); | |
// correct speeds | |
$this->soundHeader['frameSpeed'] = 8 - $this->soundHeader['frameSpeed']; | |
$this->soundHeader['BGMSpeed'] = 8 - $this->soundHeader['BGMSpeed']; | |
// figure out soundtrack offsets | |
$offset = $this->header['soundDataOffset'] + 32; | |
$this->soundHeader["BGMOffset"] = $offset; | |
$this->soundHeader["SE1Offset"] = $offset += $this->soundHeader["BGMLength"]; | |
$this->soundHeader["SE2Offset"] = $offset += $this->soundHeader["SE1Length"]; | |
$this->soundHeader["SE3Offset"] = $offset += $this->soundHeader["SE2Length"]; | |
return $this->soundHeader; | |
} | |
public function getData() | |
{ | |
return $this->$data; | |
} | |
public function getBody() | |
{ | |
$length = $this->header['soundDataOffset'] + $this->header['soundDataLength'] + 32; | |
$this->seek(0); | |
return $this->read($length); | |
} | |
public function getSignature() | |
{ | |
$this->seek($this->header['soundDataOffset'] + $this->header['soundDataLength'] + 32); | |
return $this->read(128); | |
} | |
public function getTMB() | |
{ | |
$this->seek(0); | |
return $this->read(1696); | |
} | |
// Check if the Flipnote is entirely blank | |
public function isBlank() | |
{ | |
$animationHeader = $this->getAnimationHeader(); | |
$uniqueFrameCount = count(array_unique($animationHeader['table'])); | |
// in a blank flipnote, each frame will only be 97 bytes long | |
// add this to the length of the animation size and offset table, and round to the nearest multiple of 4 | |
$targetLength = 8 + $animationHeader['tableSize'] + ($uniqueFrameCount * 97); | |
if ($targetLength % 4 !== 0) $targetLength += 4 - ($targetLength % 4); | |
return $this->header['frameDataLength'] === $targetLength; | |
} | |
// Check if the Flipnote is complete -- if not, if many have been cut off during upload | |
public function isComplete() | |
{ | |
$dataLength = strlen($this->data); | |
return $dataLength === $this->header['soundDataOffset'] + $this->header['soundDataLength'] + 32 + 128 + 16; | |
} | |
// Check if the Flipnote signature is valid using the embedded PUBLIC key | |
public function isSignatureValid() | |
{ | |
return openssl_verify($this->getBody(), $this->getSignature(), $this->pubkey, 'sha1WithRSAEncryption'); | |
} | |
public function isDataValid() | |
{ | |
if ($this->header['magic'] !== "PARA" || $this->header['version'] !== 0x0024) | |
{ | |
return false; | |
} | |
// check animation header -- this is the part that most flipnote exploits abuse | |
$animationHeader = $this->getAnimationHeader(); | |
// make sure that the table size doesn't exceed the max length for 999 frames | |
if ($animationHeader['tableSize'] > 3996) | |
{ | |
return false; | |
} | |
// AFAIK the app never sets the base offset to anything other than 0 | |
if ($animationHeader['baseOffset'] > 0) | |
{ | |
return false; | |
} | |
// check that all of the frame offsets are within a valid range | |
// (this should detect ugopwn and flipnote lenny) | |
foreach ($animationHeader['table'] as $frame => $offset) | |
{ | |
if ($offset > $this->header['frameDataLength']) | |
{ | |
return false; | |
} | |
} | |
// check sound header | |
$soundHeader = $this->getSoundHeader(); | |
// frame speed should be between 0 and 8 | |
if ($soundHeader['frameSpeed'] < 0 || $soundHeader['frameSpeed'] > 8) | |
{ | |
return false; | |
} | |
// bgm frame speed should be between 0 and 8 | |
if ($soundHeader['BGMSpeed'] < 0 || $soundHeader['BGMSpeed'] > 8) | |
{ | |
return false; | |
} | |
// check that the length of the individual audio tracks match the reported sound data length | |
if ($this->header['soundDataLength'] !== $soundHeader['BGMLength'] + $soundHeader['SE1Length'] + $soundHeader['SE2Length'] + $soundHeader['SE3Length']) | |
{ | |
return false; | |
} | |
// check meta validity | |
$meta = $this->getMeta(); | |
// check frame count is between 1 and 999 | |
if ($meta['frameCount'] < 1 || $meta['frameCount'] > 999) | |
{ | |
return false; | |
} | |
// check framecount is equal to number of table entries | |
if ($meta['frameCount'] !== $animationHeader['tableSize'] / 4) | |
{ | |
return false; | |
} | |
// check thumbnail index is within the frame count limit | |
if ($meta['thumbIndex'] > $meta['frameCount']) { | |
return false; | |
} | |
// validate FSIDs | |
foreach ([$meta['root']['fsid'], $meta['parent']['fsid'], $meta['current']['fsid']] as $fsid) | |
{ | |
if (!preg_match('/^[0159]{1}[0-9A-F]{6}0[0-9A-F]{8}$/', $fsid)) | |
{ | |
return false; | |
} | |
} | |
// validate filenames | |
foreach ([$meta['parent']['filename'], $meta['current']['filename']] as $filename) | |
{ | |
if (!preg_match('/^[0-9A-F]{6}_[0-9A-F]{13}_[0-9]{3}$/', $filename)) | |
{ | |
return false; | |
} | |
} | |
// otherwise we're all good! | |
return true; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment