前段时间参加了第五届强网杯线下赛,也是我第一次参加线下赛。经历了32个小时的鏖战,感受web题目质量还是很高的,最终勉强只做出来了几道题,和大手子们还是差的很远。现在进行一下做出来的题目的复盘,也还原一下当时的心路历程。
一、渗透01-mDMZ
0x01 代理、转发工具
刚发布题目看到是一道渗透题,真是打人一个措手不及,因为之前根本没有准备渗透需要的内容。比赛方给了一个linux的跳板机,登陆后就开始想需要代理或者端口转发的工具,还要准备扫内网找靶机。
提示说靶机是在10.0.0.0/24内,一开始就ping到了10.0.0.1和10.0.0.2两台机器,就开始找代理和转发工具,因为以前用过frp,就用frp把10.0.0.1/2的80,443都转出来,可能是第一次哪一步弄错了,没发现这两台主机开了web服务,于是就怀疑是不是还有别的机器。就又写了一个脚本去ping整个C段,跑脚本的同时去找socks5代理工具。等脚本跑完了,发现整个C段只有这两台主机可以ping通。socks5代理工具也找到了狗洞,测试的过程也吃了亏,开始用windows主机测试,使用proxifier代理本地端口,一直ping不通10.0.0.1(现在也不知道到底是怎么回事)。后来使用kali proxychain就能成功ping通10.0.0.1,访问了一下10.0.0.2:80就发现了目标网站。惊讶的我又重新配置了一遍frp,结果又能成功访问了。这时候已经十点半了,我们才开始做题- -
0x02 yzncms
简单扫了一下发现www.zip存在源码泄露,是一个php CMS yzncms。点了点功能发现都返回404,后来才发现路径还需要index.php,要在index.php/后添加内容才可以正常访问,后台地址在admin上,简单试了一下admin:admin,可以登录。点一点各个功能点,就准备部署到本地开始源码审计了。
先部署了一下数据库,然后把项目拖到phpstudy就可以访问public/index.php了。查阅了一下yzncms的相关资料,发现是基于thinkphp5的CMS框架,大概了解其MVC框架,目录结构。下载了公开的最新的yzncms项目源码,winmerge之后发现本来存在漏洞点的地方都被补了,就只能找新洞了。通过对比发现
yzncms/addons/loginbg/Loginbg.php有明显不同,而且存在漏洞风险,$config[‘pic’]如果是可控的话,11行即可以任意文件读取,结合上传文件还可以触发phar反序列化漏洞。18、19行还可能存在模板注入漏洞
public function adminLoginStyle()
{
$config = $this->getAddonConfig();
if ($config['mode'] == 'random' || $config['mode'] == 'daily') {
$gettime = $config['mode'] == 'random' ? mt_rand(-1, 7) : 0;
$json_string = file_get_contents('https://www.bing.com/HPImageArchive.aspx?format=js&idx=' . $gettime . '&n=1');
$data = json_decode($json_string);
$background = "https://www.bing.com" . $data->{"images"}[0]->{"urlbase"} . "_1920x1080.jpg";
} else {
if ($config['load'] == 'embed' && (file_exists($config['pic']) || stristr($config['pic'], 'http://') || stristr($config['pic'], 'https://'))) {
$background = 'data:image/png;base64,'.base64_encode(@file_get_contents($config['pic']));
}
else {
$background = $config['pic'];
}
}
$this->assign('background', $background);
return $this->fetch('loginbg');
}
通过进一步burp抓包测试,发现yzncms/application/addons/controller/Addons.php config函数负责处理对插件的更改,而且对LoginBp插件的Config[‘pic’]没有任何过滤。
/**
* 设置插件页面
*/
public function config($name = null)
{
$name = $name ? $name : $this->request->get("name");
if (!$name) {
$this->error('参数不得为空!');
}
if (!preg_match('/^[a-zA-Z0-9]+$/', $name)) {
$this->error('插件名称不正确!');
}
if (!is_dir(ADDON_PATH . $name)) {
$this->error('目录不存在!');
}
$info = get_addon_info($name);
$config = get_addon_fullconfig($name);
if (!$info) {
$this->error('配置不存在!');
}
if ($this->request->isPost()) {
$params = $this->request->post("config/a", [], 'trim');
if ($params) {
foreach ($config as $k => &$v) {
if (isset($params[$v['name']])) {
if ($v['type'] == 'array') {
$params[$v['name']] = is_array($params[$v['name']]) ? $params[$v['name']] : (array) json_decode($params[$v['name']],
true);
$value = $params[$v['name']];
} else {
$value = is_array($params[$v['name']]) ? implode(',',
$params[$v['name']]) : $params[$v['name']];
}
$v['value'] = $value;
}
}
try {
//更新配置文件
set_addon_fullconfig($name, $config);
//AddonService::refresh();
} catch (\Exception $e) {
$this->error($e->getMessage());
}
}
$this->success('插件配置成功!');
}
$this->assign('data', ['info' => $info, 'config' => $config]);
$configFile = ADDON_PATH . $name . DS . 'config.html';
if (is_file($configFile)) {
$this->assign('custom_config', $this->view->fetch($configFile));
}
return $this->fetch();
}
参数处理过程大概是在后台插件管理->后台登录背景插件->上传图片,提交修改->截包并修改config[pic]参数
然后打开yzncms\addons\loginbg\config.php,会发现pic部分的value已经被改变
2 =>
array (
'name' => 'pic',
'title' => '固定图片',
'type' => 'image',
'value' => 'configtest',
'tip' => '选择固定则需要上传此图片',
),
接着再退出用户,重新登录admin页面的时候则会调用yzncms/addons/loginbg/Loginbg.php adminLoginStyle漏洞函数并进行模板渲染,可以看到之前上传的内容已经被渲染进了模板。
接下来就可以通过修改config[load]和config[pic]的值来进行任意文件读取,比如读/flag
进一步利用可以构造thinkphp5反序列化RCE的phar,修改文件后缀上传,再通过这个点利用达成getshell
这道题我就只做到了这个程度,没做出来第二步有点可惜,都看不到第三步的题目。
二、OA
源码包是信呼,免费开源的办公OA系统,在readme文件中可以看到版本整理时间:2021-03-05 23:59:59
版本号:V2.2.2 通过查阅资料发现最新版本已经到2.2.7
还是先部署数据库,再拖到phpstudy就可以使用了
该题目存在数据库的admin密码甚至不是32位,但是登陆时需要比较传入的password的MD5值和数据库中的密码,所以该题无法使用admin登录,但是可以使用test用户登录,并且对MD5进行解密发现是abc123
后台有很多上传点,但是对文件后缀有严格的过滤oa/include/chajian/upfileChajian.php
/**
上传
@param $name string 对应文本框名称
@param $cfile string 文件名心的文件名,不带扩展名的
@return string/array
*/
public function up($name,$cfile='')
{
if(!$_FILES)return 'sorry!';
$file_name = $_FILES[$name]['name'];
$file_size = $_FILES[$name]['size'];//字节
$file_type = $_FILES[$name]['type'];
$file_error = $_FILES[$name]['error'];
$file_tmp_name = $_FILES[$name]['tmp_name'];
$zongmax = $this->getmaxupsize();
if($file_size<=0 || $file_size > $zongmax){
return '文件为0字节/超过'.$this->formatsize($zongmax).',不能上传';
}
$file_sizecn = $this->formatsize($file_size);
$file_ext = $this->getext($file_name);//文件扩展名
$file_img = $this->isimg($file_ext);
$file_kup = $this->issavefile($file_ext);
if(!$file_img && !$this->isoffice($file_ext) && getconfig('systype')=='demo')return '演示站点禁止文件上传';
if($file_error>0){
$rrs = $this->geterrmsg($file_error);
return $rrs;
}
if(!$this->contain('|'.$this->ext.'|', '|'.$file_ext.'|') && $this->ext != '*'){
return '禁止上传文件类型['.$file_ext.']';
}
if($file_size>$this->maxsize*1024*1024){
return '上传文件过大,限制在:'.$this->formatsize($this->maxsize*1024*1024).'内,当前文件大小是:'.$file_sizecn.'';
}
//创建目录
$zpath=explode('|',$this->path);
$mkdir='';
for($i=0;$i<count($zpath);$i++){
$mkdir.=''.$zpath[$i].'/';
if(!is_dir($mkdir))mkdir($mkdir);
}
//新的文件名
$file_newname = $file_name;
$randname = $file_name;
if(!$cfile==''){
$file_newname=''.$cfile.'.'.$file_ext.'';
}else{
$_oldval = m('option')->getval('randfilename');
$randname = $this->getrandfile(1, $_oldval);
m('option')->setval('randfilename', $randname);
$file_newname=''.$randname.'.'.$file_ext.'';
}
$save_path = ''.str_replace('|','/',$this->path);
//if(!is_writable($save_path))return '目录'.$save_path.'无法写入不能上传';
$allfilename= $save_path.'/'.$file_newname.'';
$uptempname = $save_path.'/'.$randname.'.uptemp';
$upbool = true;
if(!$file_kup){
$allfilename= $this->filesave($file_tmp_name, $file_newname, $save_path, $file_ext);
if(isempt($allfilename))return '无法保存到'.$save_path.'';
}else{
$upbool = @move_uploaded_file($file_tmp_name,$allfilename);
}
if($upbool){
$picw=0;$pich=0;
if($file_img){
$fobj = $this->isimgsave($file_ext, $allfilename);
if(!$fobj){
return 'error:非法图片文件';
}else{
$picw = $fobj[0];
$pich = $fobj[1];
}
}
return array(
'newfilename' => $file_newname,
'oldfilename' => $file_name,
'filesize' => $file_size,
'filesizecn' => $file_sizecn,
'filetype' => $file_type,
'filepath' => $save_path,
'fileext' => $file_ext,
'allfilename' => $allfilename,
'picw' => $picw,
'pich' => $pich
);
}else{
return '上传失败:'.$this->geterrmsg($file_error).'';
}
}
23行$file_kup = $this->issavefile($file_ext);
判断是否是合法的后缀,允许的后缀存在类的声明中:
//可上传文件类型,也就是不保存为uptemp的文件
private $upallfile = '|doc|docx|xls|xlsx|ppt|pptx|pdf|swf|rar|zip|txt|gz|wav|mp3|avi|mp4|flv|wma|chm|apk|amr|log|json|cdr|';
67行 $allfilename= $this->filesave($file_tmp_name, $file_newname, $save_path, $file_ext);
不允许的后缀都会更改后缀,保存为.uptemp文件
include/chajian/upfileChajian.php
public function filesave($oldfile, $filename, $savepath, $ext)
{
$file_kup = $this->issavefile($ext);
$ldisn = strrpos($filename, '.');
if($ldisn>0)$filename = substr($filename, 0, $ldisn);
$filepath = ''.$savepath.'/'.$filename.'.'.$ext.'';
if(!$file_kup){
$filebase64 = base64_encode(file_get_contents($oldfile));
$filepath = ''.$savepath.'/'.$filename.'.uptemp';
$bo = $this->rock->createtxt($filepath, $filebase64);
@unlink($oldfile);
if(!$bo)$filepath = '';
}else{
}
return $filepath;
}
观察数据库可以发现,每一次上传文件都会保存上传文件的原文件名,现文件路径,并且在主页时会调用数据库的内容显示原文件名。
经过搜查公开资料学习到信呼的文件目录和代码框架,审计到oa/webmain/task/runt/qcloudCosAction.php
中runAction函数会改变文件后缀为原名称。那么就可以通过上传php文件,生成uptemp文件,再通过runAction修改后缀即可以直接getshell。
/**
* 发送上传文件
* php task.php qcloudCos,run -fileid=1
* http://你地址/task.php?m=qcloudCos|runt&a=run&fileid=文件id
*/
public function runAction()
{
$fileid = (int)$this->getparams('fileid','0'); //文件ID
if($fileid<=0)return 'error fileid';
$frs = m('file')->getone($fileid);
if(!$frs)return 'filers not found';
$filepath = $frs['filepath'];
if(substr($filepath, 0, 4)=='http')return 'filepath is httppath';
if(substr($filepath,-6)=='uptemp'){
$aupath = ROOT_PATH.'/'.$filepath;
$nfilepath = str_replace('.uptemp','.'.$frs['fileext'].'', $filepath);
$content = file_get_contents($aupath);
$this->rock->createtxt($nfilepath, base64_decode($content));
unlink($aupath);
$filepath = $nfilepath;
}
$msg = $this->sendpath($filepath, $frs, 'filepathout');
if($msg)return $msg;
$thumbpath = $frs['thumbpath'];
if(!isempt($thumbpath)){
$msg = $this->sendpath($thumbpath, $frs, 'thumbplat');
if($msg)return $msg;
}
return 'success';
}
三、Rua
题目p.php执行phpinfo,file.php支持文件读取,利用file.php读file.php
<?php
if(stripos($_GET['file'], "gopher") !== FALSE)
die("no gopher, try to find another ssrf on this server!");
else
echo file_get_contents($_GET['file']);
根据phpinfo allow_url_fopen=On,再结合gopher和file_get_contents,利用file_get_contents读/flag了解到这道题是考察ssrf
根据phpinfo获取网站目录,nginx配置目录并读取
- 读/etc/hosts 得到内网ip和网段
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.18.0.2 ae0ad8408c36
- nginx.conf 了解有http服务
user root;
worker_processes 1;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#access_log logs/access.log main;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
#gzip on;
include /etc/nginx/conf.d/*.conf;
}
3.default.conf
这个文件很关键,尤其是46行 /api的路由,指示了题目下一步的方向
lua_package_path '/usr/local/openresty/lualib/resty/?.ljbc;;';
lua_ssl_verify_depth 2;
lua_ssl_trusted_certificate '/etc/ssl/certs/ca-certificates.crt';
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent ';
server {
listen 80;
server_name localhost;
access_log /usr/local/openresty/nginx/logs/access.log main;
access_log /usr/local/openresty/nginx/logs/access2.log main;
location / {
root /usr/local/openresty/nginx/html;
index index.html index.htm index.php;
try_files $uri $uri/ /index.php?$query_string;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/local/openresty/nginx/html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
location ~ \.php$ {
fastcgi_pass unix:/dev/shm/php-cgi.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location /api {
default_type text/html;
content_by_lua_block {
local httpd = require "resty.http_dns"
ngx.req.read_body()
local args = ngx.req.get_uri_args()
local headers = ngx.req.get_headers()
local post_data = ngx.req.get_body_data()
local url = args.url
local domain = ngx.re.match(url, [[//([\S]+?)/]])
domain = (domain and 1 == #domain and domain[1]) or nil
if domain == "sisselcbp.github.io" then
local res = httpd:http_request_with_dns(url,{})
ngx.print(res.body)
elseif domain == "r3kapig.com" then
local res = httpd:http_request_with_dns(url,{
method = "POST",
body = post_data,
headers = {
["Content-Type"] = headers["Content-Type"]
}
})
ngx.print(res.body)
else
ngx.print("Error! Try it local to read the log!")
end
}
}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
不难看出该服务是由lua语言写的,
49行local httpd = require "resty.http_dns"
在github上lua-resty-http能找到部分源码
1行 lua_package_path '/usr/local/openresty/lualib/resty/?.ljbc;;';
能读到http.ljbc,http_dns.ljbc,开始还想做一下反编译的工作,结果查了好多资料和工具也没能成功。最后在http://moguhu.com/article/detail?articleId=82可以找到http_dns.lua源码
local http = require "resty.http"
local resolver = require "resty.dns.resolver"
local _M = {}
_M._VERSION="0.1"
function _M:http_request_with_dns( url, param )
-- get domain
local domain = ngx.re.match(url, [[//([\S]+?)/]])
domain = (domain and 1 == #domain and domain[1]) or nil
if not domain then
ngx.log(ngx.ERR, "get the domain fail from url:", url)
return {status=ngx.HTTP_BAD_REQUEST}
end
-- add param
if not param.headers then
param.headers = {}
end
param.headers.Host = domain
-- get domain ip
local domain_ip, err = self:get_domain_ip_by_dns(domain)
if not domain_ip then
ngx.log(ngx.ERR, "get the domain[", domain ,"] ip by dns failed:", err)
return {status=ngx.HTTP_SERVICE_UNAVAILABLE}
end
-- http request
local httpc = http.new()
local temp_url = ngx.re.gsub(url, "//"..domain.."/", string.format("//%s/", domain_ip))
local res, err = httpc:request_uri(temp_url, param)
if err then
return {status=ngx.HTTP_SERVICE_UNAVAILABLE}
end
-- httpc:request_uri 内部已经调用了keepalive,默认支持长连接
-- httpc:set_keepalive(1000, 100)
return res
end
-- 根据域名获取IP地址
function _M:get_domain_ip_by_dns( domain )
-- 这里写死了google的域名服务ip,要根据实际情况做调整(例如放到指定配置或数据库中)
local dns = "8.8.8.8"
local r, err = resolver:new{
nameservers = {dns, {dns, 53} },
retrans = 5, -- 5 retransmissions on receive timeout
timeout = 2000, -- 2 sec
}
if not r then
return nil, "failed to instantiate the resolver: " .. err
end
local answers, err = r:query(domain)
if not answers then
return nil, "failed to query the DNS server: " .. err
end
if answers.errcode then
return nil, "server returned error code: " .. answers.errcode .. ": " .. answers.errstr
end
for i, ans in ipairs(answers) do
if ans.address then
return ans.address
end
end
return nil, "not founded"
end
return _M
继续分析/api路由的处理逻辑:
location /api {
default_type text/html;
content_by_lua_block {
local httpd = require "resty.http_dns"
ngx.req.read_body()
local args = ngx.req.get_uri_args()
local headers = ngx.req.get_headers()
local post_data = ngx.req.get_body_data()
local url = args.url
local domain = ngx.re.match(url, [[//([\S]+?)/]])
domain = (domain and 1 == #domain and domain[1]) or nil
if domain == "sisselcbp.github.io" then
local res = httpd:http_request_with_dns(url,{})
ngx.print(res.body)
elseif domain == "r3kapig.com" then
local res = httpd:http_request_with_dns(url,{
method = "POST",
body = post_data,
headers = {
["Content-Type"] = headers["Content-Type"]
}
})
ngx.print(res.body)
else
ngx.print("Error! Try it local to read the log!")
end
}
可以看出先获取url中的url参数赋值url变量,经过正则处理后赋值domain变量,经过比对后http_request_with_dns访问url,这里存在一个针对正则的绕过,给出payload:
\api?url=http://127.0.0.1:80 //sisselcbp.github.io/
即可绕过[[//([\S]+?)/]](非空连续字符)对127.0.0.1:80的匹配,使得domain赋值sisselcbp.github.io成功的情况下可控实际访问的url。
接着找到内网靶机修改domain为r3kapig.com,按要求发送post包即可get flag
四、写在最后
断断续续学习CTF也有快两年时间了,参加了大大小小的比赛,从一开始看不懂题目到后来渐渐有了眉目,甚至能解出来一两道题。通过学习CTF给我带来了很多的快乐和成就感,并且也为我的工作提供了很大帮助。这次能参加线下赛也是对我这段学习时间的认可,而且还能做出题来真是太开心了,接下来的日子继续学学学,冲冲冲!