Skip to content

Instantly share code, notes, and snippets.

@jaames
Last active January 14, 2021 22:17
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jaames/3cf757bbbc2de664e1e33955a7a1930b to your computer and use it in GitHub Desktop.
Save jaames/3cf757bbbc2de664e1e33955a7a1930b to your computer and use it in GitHub Desktop.
new php PPM metadata parser
<?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