关于Phar的深入探索与发现

关于Phar的深入探索与发现

相信大多数师傅应该都是和我之前一样, 对pahr的了解主要就是局限于它的压缩包功能和反序列化的使用, 但其实除了这两个功能外phar还提供了很多的其他函数, 甚至可以直接将一个phar文件作为一个简单的php服务运行.这几天因为一点别的原因我就对phar的使用做了更多的探索, 相信看了这篇文章之后可以解决不少小伙伴对phar的一些疑问

经过几天的php源码调试和phar功能学习, 在这里写一个关于phar进一步探索的小总结吧

本文首发于奇安信攻防社区: 关于Phar的深入探索与发现

文件结构

我们先看一下官方对phar文件结构的说明:

image-20221008022712956

Stub存根

Stub部分我们之前一般都是使用以下代码设置:

$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub

这里的php格式代码在第一次学习phar文件的时候就激发了我的好奇心, 这段代码什么时候会被执行, __HALT_COMPILER又有什么用, 这里的代码能不能被我们利用,如果可以被利用的话什么时候可以使用, 这些问题一直以来都困扰着我, 直到最近才解决

__HALT_COMPILER

我们先来看一下__HALT_COMPILER这个函数的作用

image-20221008024527648

这个函数是用于物理截断文件, 什么意思呢, 就相当于我们执行代码的时候后面的内容直接全部忽略, 一旦读取到__HALT_COMPILER就不再获取后面的内容

来个简单示例:

image-20221008024846072

了解了它的作用之后我们再回到phar文件中, 我们需要知道为什么要用它,

在pahr的stub存根中, __HALT_COMPILER();是必须要有的, 至于其他的内容其实是可有可无的, 在它后面的数据会直接被丢弃, 前面的内容则是直接原封不动的获取然后放到文件头中并且在后面加上?>

image-20221008030224707

为什么要使用__HALT_COMPILER

之前我对stub存根数据格式定义为php代码格式一直都是很不理解的, 直到我这几天看到了它作为web服务的.php以及.phps用法的时候我才明白

实际上phar格式除了定义为压缩包的格式之外还可以变为.php格式或者.phps格式的文件, 并且可以直接作为一个服务目录直接部署, 而__HALT_COMPILER()就是为了避免.php或者.phps格式的文件执行stub存根的代码的时候受到文件内容数据的影响,详细的继续往后看吧

文件描述和条目数据

从学习中可以知道这段数据主要由两个部分组成: 别名+Metadata反序列化元数据

<?php
class TestObject {
}

@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->setAlias('h0cksr');
$phar->startBuffering();
$phar->setStub("<? __HALT_COMPILER();?>"); //设置stub
$o = new TestObject();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
$phar->addFromString("flag.txt", "flag{test}"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
system("hexdump phar.phar")
?>

生成的phar.phar文件16进制输出如下:

00000000: 3C 3F 20 5F 5F 48 41 4C  54 5F 43 4F 4D 50 49 4C  <? __HALT_COMPIL
00000010: 45 52 28 29 3B 20 3F 3E  0D 0A 76 00 00 00 02 00  ER(); ?>..v.....
00000020: 00 00 11 00 00 00 01 00  06 00 00 00 68 30 63 6B  ............h0ck
00000030: 73 72 16 00 00 00 4F 3A  31 30 3A 22 54 65 73 74  sr....O:10:"Test
00000040: 4F 62 6A 65 63 74 22 3A  30 3A 7B 7D 08 00 00 00  Object":0:{}....
00000050: 74 65 73 74 2E 74 78 74  04 00 00 00 CD 7C 40 63  test.txt.....|@c
00000060: 04 00 00 00 0C 7E 7F D8  B6 01 00 00 00 00 00 00  .....~..........
00000070: 08 00 00 00 66 6C 61 67  2E 74 78 74 0A 00 00 00  ....flag.txt....
00000080: CD 7C 40 63 0A 00 00 00  F9 67 6B 85 B6 01 00 00  .|@c.....gk.....
00000090: 00 00 00 00 74 65 73 74  66 6C 61 67 7B 74 65 73  ....testflag{tes
000000A0: 74 7D 78 A3 03 F7 CB 91  1F 44 FC 0E 7E 76 80 70  t}x......D..~v.p
000000B0: 3C DC 7A 6F 6D 14 02 00  00 00 47 42 4D 42        <.zom.....GBMB

可以看到在stub存根<? __HALT_COMPILER(); ?>之后跟着的数据中有我们设置的别名h0cksr,还有反序列化Matedata元数据O:10:"TestObject":0:{}

至于其它的不可见数据, 还没从源码仔细深挖过, 但是我想应该是别名以及Metadata原数据的一些16进制标识符以及一些相关信息吧

然后找手册翻了一会之后至于找到那些不可见字符格式的16进制数据所代表的含义了:

image-20221008034004681

文件数据

文件数据部分我们的文件名和文件内容的数据可以说是原封不动的可以看到了, 简单观察可以看到它是先有全部的文件名在前面(我想这应该是以phar的某种格式, 通过特定的格式定义还原拿到目录结构)然后后面接的就是文件内容了,而且这些文件的内容是无缝衔接直接连在一起的, 所以我们可以合理推断前面的文件名那部分数据不仅定义了文件结构, 而且文件大小也是在里面的

之后回去看一下可以发现确实如此, 在原始的文件名数据部分中, 它的两个文件数据部分结构均为文件名+文件长度+7字节的16进制数据000000CD7C4063(这些都是使用pack压缩打包的数据,文章后面我们会说到使用unpack将其解压缩)

文件签名

Phar支持的签名格式有下面几种:

MD5/SHA1/SHA256/SHA512/OpenSSL

pahr默认使用sha1加密就是有20字节的签名生成结果, 在签名后面还有8字节,前4字节表示文件使用的签名算法,最后四字节固定用于表示该文件存在签名

因为签名是整个pahr最后的数据段, 所以默认情况下我们可以知道文件最后的28字节为:

签名(默认sha1有20字节大小)+签名方式(4字节)+声明文件有无签名(4字节)

了解了phar的整个文件结构之后, 就让我们看一下phar的文件都有哪些使用方法吧:

作为普通的压缩文件使用

作为一般的压缩文件可以说是我们最基本的使用方法了, 对于这点, 如果是常规的单个文件的存储获取这些我并不想多说, 一般的用法就是通过phar:///path/to/phar.phar/pahrdir/pharfile读出文件内容

作为一个服务压缩包启用

我们需要先明白一点, 那就是在pahr压缩包中的文件如果被包含了的话那么对于里面的代码来说他们是在一个全新的目录结构中的, 并且phar压缩包中的文件之间是可以相互包含的

image-20221008040543031

可以看到,我们在一个文件中包含一个pahr压缩包中的文件和普通的文件包含并没有什么区别, 都可以传递全局变量

但是有一点是很大的不同, 那就是输出__FILE__可以看到显示的文件路径直接就是phar://协议的文件路径, 使用include文件包含的时候默认包含的是当前phar压缩包内同目录下的文件

另外还有一点是需要区别的, 那就是在test1.php中对test2.php./test2.php的解析效果是不一样的

  • test2.php指向的是pahr://phar.phar/test2.php
  • 表示test2.php指向的是C:\phpstudy_pro\WWW\test\test2.php

我们先继续包含默认的test2.php, 然后在test2.php添加一个var_dump(scandir("."))扫描当前目录并且输出看一下输出的结果是phar://phar.phar的目录还是C:\phpstudy_pro\WWW\test的目录

image-20221008041427445

可以看到输出显示的是C:\phpstudy_pro\WWW\test目录扫描结果,这时候我们再将test1.php中包含的文件改为./test2.php看一下会不会成功包含解析

image-20221008041420881

从结果可以看到并没有执行phar://phar.phar/test2.php的代码, 这就说明其寻找解析的应该是C:\phpstudy_pro\WWW\test\test2.php(但是不存在这个文件)

作为一个服务页面启用

下面我们就看一下一个phar结构的文件是如何以.php的格式直接运行或者.phps被包含如何直接启动一个服务的.

在使用.php.phps之前先继续使用.pahr格式的数据看一下默认的Stub存根数据

我们上面了解文件结构的时候使用setStub函数设置了存根数据, 那么如果我们不设置的话会不会报错呢?

答案是不会的, 并且会使用原生默认的stub数据, 执行一下可以看到():

image-20221008034744813

我们这时候可以看到, 在注释了setStub函数之后还是会有存根代码, 依旧为一段php代码, 而且还很长, 将这段代码拉出来看一下吧:

image-20221008035039396

存根数据会被放到stub.php

这里的代码说长不长说短不短, 这段代码放在phar里面我们或许并没有太多的感觉, 但是如果我们将它放在一个.php文件中呢???

修改一下phar的生成代码:

<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->setAlias('h0cksr');
$phar->startBuffering();
/*$phar->setStub("<? __HALT_COMPILER();?>"); //设置stub*/
$o = new TestObject();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("index.php","<?php echo 'this is index.php';?>");
//签名自动计算
$phar->stopBuffering();
?>

我们直接给phar.phar添加一个.php后缀名,变为phar.phar.php, 复制的数据如下:

<?php

$web = 'index.php';

if (in_array('phar', stream_get_wrappers()) && class_exists('Phar', 0)) {
Phar::interceptFileFuncs();
set_include_path('phar://' . __FILE__ . PATH_SEPARATOR . get_include_path());
Phar::webPhar(null, $web);
include 'phar://' . __FILE__ . '/' . Extract_Phar::START;
return;
}

if (@(isset($_SERVER['REQUEST_URI']) && isset($_SERVER['REQUEST_METHOD']) && ($_SERVER['REQUEST_METHOD'] == 'GET' || $_SERVER['REQUEST_METHOD'] == 'POST'))) {
Extract_Phar::go(true);
$mimes = array(
'phps' => 2,
'c' => 'text/plain',
'cc' => 'text/plain',
'cpp' => 'text/plain',
'c++' => 'text/plain',
'dtd' => 'text/plain',
'h' => 'text/plain',
'log' => 'text/plain',
'rng' => 'text/plain',
'txt' => 'text/plain',
'xsd' => 'text/plain',
'php' => 1,
'inc' => 1,
'avi' => 'video/avi',
'bmp' => 'image/bmp',
'css' => 'text/css',
'gif' => 'image/gif',
'htm' => 'text/html',
'html' => 'text/html',
'htmls' => 'text/html',
'ico' => 'image/x-ico',
'jpe' => 'image/jpeg',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'js' => 'application/x-javascript',
'midi' => 'audio/midi',
'mid' => 'audio/midi',
'mod' => 'audio/mod',
'mov' => 'movie/quicktime',
'mp3' => 'audio/mp3',
'mpg' => 'video/mpeg',
'mpeg' => 'video/mpeg',
'pdf' => 'application/pdf',
'png' => 'image/png',
'swf' => 'application/shockwave-flash',
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
'wav' => 'audio/wav',
'xbm' => 'image/xbm',
'xml' => 'text/xml',
);

header("Cache-Control: no-cache, must-revalidate");
header("Pragma: no-cache");

$basename = basename(__FILE__);
if (!strpos($_SERVER['REQUEST_URI'], $basename)) {
chdir(Extract_Phar::$temp);
include $web;
return;
}
$pt = substr($_SERVER['REQUEST_URI'], strpos($_SERVER['REQUEST_URI'], $basename) + strlen($basename));
if (!$pt || $pt == '/') {
$pt = $web;
header('HTTP/1.1 301 Moved Permanently');
header('Location: ' . $_SERVER['REQUEST_URI'] . '/' . $pt);
exit;
}
$a = realpath(Extract_Phar::$temp . DIRECTORY_SEPARATOR . $pt);
if (!$a || strlen(dirname($a)) < strlen(Extract_Phar::$temp)) {
header('HTTP/1.0 404 Not Found');
echo "<html>\n <head>\n  <title>File Not Found<title>\n </head>\n <body>\n  <h1>404 - File Not Found</h1>\n </body>\n</html>";
exit;
}
$b = pathinfo($a);
if (!isset($b['extension'])) {
header('Content-Type: text/plain');
header('Content-Length: ' . filesize($a));
readfile($a);
exit;
}
if (isset($mimes[$b['extension']])) {
if ($mimes[$b['extension']] === 1) {
include $a;
exit;
}
if ($mimes[$b['extension']] === 2) {
highlight_file($a);
exit;
}
header('Content-Type: ' .$mimes[$b['extension']]);
header('Content-Length: ' . filesize($a));
readfile($a);
exit;
}
}

class Extract_Phar
{
static $temp;
static $origdir;
const GZ = 0x1000;
const BZ2 = 0x2000;
const MASK = 0x3000;
const START = 'index.php';
const LEN = 6643;

static function go($return = false)
{
$fp = fopen(__FILE__, 'rb');
fseek($fp, self::LEN);
$L = unpack('V', $a = fread($fp, 4));
$m = '';

do {
$read = 8192;
if ($L[1] - strlen($m) < 8192) {
$read = $L[1] - strlen($m);
}
$last = fread($fp, $read);
$m .= $last;
} while (strlen($last) && strlen($m) < $L[1]);

if (strlen($m) < $L[1]) {
die('ERROR: manifest length read was "' .
strlen($m) .'" should be "' .
$L[1] . '"');
}

$info = self::_unpack($m);
$f = $info['c'];

if ($f & self::GZ) {
if (!function_exists('gzinflate')) {
die('Error: zlib extension is not enabled -' .
' gzinflate() function needed for zlib-compressed .phars');
}
}

if ($f & self::BZ2) {
if (!function_exists('bzdecompress')) {
die('Error: bzip2 extension is not enabled -' .
' bzdecompress() function needed for bz2-compressed .phars');
}
}

$temp = self::tmpdir();

if (!$temp || !is_writable($temp)) {
$sessionpath = session_save_path();
if (strpos ($sessionpath, ";") !== false)
$sessionpath = substr ($sessionpath, strpos ($sessionpath, ";")+1);
if (!file_exists($sessionpath) || !is_dir($sessionpath)) {
die('Could not locate temporary directory to extract phar');
}
$temp = $sessionpath;
}

$temp .= '/pharextract/'.basename(__FILE__, '.phar');
self::$temp = $temp;
self::$origdir = getcwd();
@mkdir($temp, 0777, true);
$temp = realpath($temp);

if (!file_exists($temp . DIRECTORY_SEPARATOR . md5_file(__FILE__))) {
self::_removeTmpFiles($temp, getcwd());
@mkdir($temp, 0777, true);
@file_put_contents($temp . '/' . md5_file(__FILE__), '');

foreach ($info['m'] as $path => $file) {
$a = !file_exists(dirname($temp . '/' . $path));
@mkdir(dirname($temp . '/' . $path), 0777, true);
clearstatcache();

if ($path[strlen($path) - 1] == '/') {
@mkdir($temp . '/' . $path, 0777);
} else {
file_put_contents($temp . '/' . $path, self::extractFile($path, $file, $fp));
@chmod($temp . '/' . $path, 0666);
}
}
}

chdir($temp);

if (!$return) {
include self::START;
}
}

static function tmpdir()
{
if (strpos(PHP_OS, 'WIN') !== false) {
if ($var = getenv('TMP') ? getenv('TMP') : getenv('TEMP')) {
return $var;
}
if (is_dir('/temp') || mkdir('/temp')) {
return realpath('/temp');
}
return false;
}
if ($var = getenv('TMPDIR')) {
return $var;
}
return realpath('/tmp');
}

static function _unpack($m)
{
$info = unpack('V', substr($m, 0, 4));
 $l = unpack('V', substr($m, 10, 4));
$m = substr($m, 14 + $l[1]);
$s = unpack('V', substr($m, 0, 4));
$o = 0;
$start = 4 + $s[1];
$ret['c'] = 0;

for ($i = 0; $i < $info[1]; $i++) {
 $len = unpack('V', substr($m, $start, 4));
$start += 4;
 $savepath = substr($m, $start, $len[1]);
$start += $len[1];
   $ret['m'][$savepath] = array_values(unpack('Va/Vb/Vc/Vd/Ve/Vf', substr($m, $start, 24)));
$ret['m'][$savepath][3] = sprintf('%u', $ret['m'][$savepath][3]
& 0xffffffff);
$ret['m'][$savepath][7] = $o;
$o += $ret['m'][$savepath][2];
$start += 24 + $ret['m'][$savepath][5];
$ret['c'] |= $ret['m'][$savepath][4] & self::MASK;
}
return $ret;
}

static function extractFile($path, $entry, $fp)
{
$data = '';
$c = $entry[2];

while ($c) {
if ($c < 8192) {
$data .= @fread($fp, $c);
$c = 0;
} else {
$c -= 8192;
$data .= @fread($fp, 8192);
}
}

if ($entry[4] & self::GZ) {
$data = gzinflate($data);
} elseif ($entry[4] & self::BZ2) {
$data = bzdecompress($data);
}

if (strlen($data) != $entry[0]) {
die("Invalid internal .phar file (size error " . strlen($data) . " != " .
$stat[7] . ")");
}

if ($entry[3] != sprintf("%u", crc32($data) & 0xffffffff)) {
die("Invalid internal .phar file (checksum error)");
}

return $data;
}

static function _removeTmpFiles($temp, $origdir)
{
chdir($temp);

foreach (glob('*') as $f) {
if (file_exists($f)) {
is_dir($f) ? @rmdir($f) : @unlink($f);
if (file_exists($f) && is_dir($f)) {
self::_removeTmpFiles($f, getcwd());
}
}
}

@rmdir($temp);
clearstatcache();
chdir($origdir);
}
}

Extract_Phar::go();
__HALT_COMPILER(); ?>S             h0cksr   O:10:"TestObject":0:{}     index.php!   _�@c!   �(�'�      <?php echo 'this is index.php';?>�Vpi����Ӿ�\v����x   GBMB

可以看到有两百多行代码, 但是它的结构是很简单的,就是两个if判断语句还有一个Extract_Phar::go();(第二个if语句也会执行这个go函数)

image-20221008053517106

第一个if(事实上一般我们都是在第一个if就结束了):

判断pahr在不在当前运行系统中已经注册并可使用的流类型列表中, 并且判断是否存在Phar类, 如果都有的话进入第一个if:

前面几行代码中我们只需要关注两行代码:

set_include_path('phar://' . __FILE__ . PATH_SEPARATOR . get_include_path());
include 'phar://' . __FILE__ . '/' . Extract_Phar::START;//Extract_Phar::START默认就是index.php

第一行代码先是将当前文件经过phar://解析后作为第一优先级的包含目录,

第二行代码使用phar://解析当前文件并且包含pahr包里的index.php文件

第二个if

第二个if先是执行Extract_Phar::go(true);,然后就是进行服务逻辑判断, 中间进行了很多的请求判断最终返回相应的结果到客户端

Extract_Phar::go(true);

这个go函数就是用于解压phar文件的, 因为后面这些代码都是在第一个if判断不满足, 也就是当前拓展不直接顺利解压pahr文件的时候执行的, 所以可以说代码就是使用php代码的形式实现pahr压缩包的解压了.
可以看到里面使用的_unpack函数对phar的原始数据进行了多次多点的unpack数据解压, 从而得到数据的目录结构,文件信息以及文件内容,将其一一对应起来

此外有一个变量参数是值得我们注意的, 那就是Extract_Phar::LEN
这个变量可以在代码中看到在读取数据进行解压之前先是读取了这个LEN并且移动指针偏移LEN个字节,然后再进行数据读取和解压工作
默认情况下我们之所以任意在Stub添加数据的原因就是因为这个LEN,在pahr格式的文件中有对应的LEN大小pack压缩数据指定文件头偏移

一次失败的尝试小记

虽然说没有成功但也算是一次简单的思维发散了, 所以就记一下吧

在整个默认的stub存根代码中最引起我关注的便是Extract_Phar::go的解压写入文件的过程, 因为开始的时候我认为后面的代码是在默认不支持phar文件格式的解压的情况下执行的php代码, 正是这些php代码的功能完成了phar文件的解压, 所以我就认为默认的phar解释器对其进行phar协议数据解析的时候操作的实际解压过程也和这里的php源码一样, 如果是一样的话, 那么我们就可以任意文件写了, 因为它的文件名是从原始数据unpack解压拿到的, 而且会将这个文件名直接拼接到临时文件夹目录后,然后将原始文件数据写入到临时文件夹下的历史文件中, 那么这时候我们是否可以将文件名修改为../../../多个上级目录/tmp/php然后往/tmp/php写文件(或者像之前p牛一样通过非法文件名让文件驻留在解压后的目录下)?

但是其实不然, 在我使用010editor修改pahr文件中的文件名, 然后又进行哈希修复后发现并没有成功, 从始至终我们都没有看到phar往/tmp生成任何文件, 我的尝试步骤如下:

  1. 生成有两个文件012345678901234567890123456789012.php1.php的pahr压缩包pahr.phar
  2. 使用010editor012345678901234567890123456789012.php修改为../../../../../../../../../../tmp/php
  3. 使用签名修复脚本更新签名
  4. 使用fswatch监控/tmp目录文件的变化
  5. 执行php测试代码执行file_get_contents使用pahr://协议解析修改后的pahr压缩包取出里面的文件输出文件内容
  6. 最后显示结果是/tmp下面并没有解压生成/tmp/php文件, 而pahr压缩包中的1.php被成功输出说明签名修正是没有问题的, 但是输出原本的012345678901234567890123456789012.php三种方式均失败了

image-20221008072218482

之后看到默认的stub存根代码中会在临时目录下创建文件夹pharextract, 然后我就到php源码中检索了一下这个关键字, 结果是None, 这就说明php解释器对phar文件的解析确实是与默认stub存根中Extract_Phar::go函数的代码是有所区别的

对于文件解压的过程我并没有去debug到详细的函数点, 但是对解析报错的语句进行代码溯源确定到了PHP解释器源代码中的ext/phar/util.c:342 phar_get_entry_data, 正是在这个函数中获取资源失败返回了FAILURE才导致输出警告pahr error:"xxx" is not a file in phar "phar.phar"但是往回走找到解压函数以及接续往下走找到更深更详细的函数代码点我并没有做, 在这里放一个调试的调用栈感兴趣的师傅可以去跟一下:

image-20221008074419462

更多其他...

至于其他一些有意思的用法这里简单的说两个吧:

别名使用

image-20221008075012654

但是这个别名的使用只能在生成文件的当前以及phar的内部, 如果将这个生成的pahr.phar放到一个新的目录下, 任何在一个test.php中写入以下测试代码:

<?php
var_dump(file_get_contents("phar://h0cksr/1.php"));

此时执行test.php就会失败了,因为此时并不知道这个别名,只会将h0cksr作为文件查找

image-20221008075335492

Phar::webPhar

<?php
// creating the phar archive:
try {
    $phar = new Phar('myphar.phar');
    $phar['index.php'] = '<?php echo "Hello World,this is index.php"; ?>';
    $phar['hello.php'] = '<?php echo "Hello World,this is hello.php"; ?>';
    $phar['default.php'] = '<?php echo "Hello World,this is default.php"; ?>';
    $phar->setStub('<?php
Phar::webPhar("","index.php","default.php");
__HALT_COMPILER(); ?>');
} catch (Exception $e) {
    // handle error here
}
?>

生成myphar.phar后重命名为myphar.phar.php,任何就可以将其作为一个服务访问了

在Stub存根中的Phar::webPhar函数作用就是指定服务的默认加载文件, 指定如果访问的服务资源不存在时加载的文件

以上demo中默认首页加载index.php, 访问的资源不存在时加载default.php, 我们定义的hello.php可以当做一个资源访问:

/myphar.phar.php

image-20221008080615821

/myphar.phar.php/hello.php

image-20221008080645778

/myphar.phar.php/xxx.php

image-20221008080707578

此外Phar::webPhar的第一个参数可以使用别名指定phar压缩包, 指定之后后面的首页资源路径和默认资源路径均是别名对应压缩包下的资源文件

相关类和函数

2022_10_08 09:30

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇