bugku-awd-3s-6
一、MySQL 3306 对外 + cms 固定弱口令 + ALL PRIVILEGES(根因)
漏洞类型
数据库弱口令 + 权限配置不当。这场最致命的一个,赛后复盘才彻底搞明白。
形成原因
所有靶机的core/config.php里,MySQL 密码是同一个固定值eb0d39d2931c480f,而且cms用户是cms@'%'——任意主机都能连,权限还是ALL PRIVILEGES ON *.* WITH GRANT OPTION。MySQL 又开了bind_address=*,3306 端口直接暴露在公网。换句话说:知道这个固定密码的人,就能直连的我们数据库干任何事。
14:40 对手就是这么打进来的:直连 3306 →SET GLOBAL general_log把 webshell 写到/var/www/html/cache/sh96.php→ 顺手把mimelist改成放行 .php → 再改掉 admin 密码 → 14:59 用改后的密码登后台传了第二个 webshell。一开始还以为是 Web 注入,后来翻日志、看文件 owner(是mysql:mysql)才确认是直连数据库。
原代码截图
源码
<?php/* 数据库配置 */$_L['mysql']['host']="127.0.0.1";$_L['mysql']['port']=3306;$_L['mysql']['user']="cms";$_L['mysql']['pass']="eb0d39d2931c480f";// ← 第 6 行,固定弱口令$_L['mysql']['name']="cms";$_L['mysql']['pre']="lcms_";$_L['mysql']['charset']="utf8mb4";修改后的代码
config.php同步成新密码(fix_mysql.py干的活):
$_L['mysql']['pass']="cms_Awd_2026#Xy9k";数据库侧两步(fix_mysql.py+ 漏洞总结 13.3):
-- 1. 改 cms 密码,让旧固定密码失效ALTERUSER'cms'@'%'IDENTIFIEDBY'cms_Awd_2026#Xy9k';FLUSHPRIVILEGES;-- 2. 收紧权限:就算再被直连,也写不了文件、改不了全局变量REVOKESUPER,FILE,GRANTOPTIONON*.*FROM'cms'@'%';小记:
bind_address=*其实改不掉(要 root 重启 mysqld,我们没 root),所以只能靠「改密码 + 收权限」兜住。改完我用旧密码试连,已经进不去了,web 功能也正常,才放下心。
二、index.php 一句话后门
漏洞类型
Webshell 后门 / 远程命令执行(RCE)
形成原因
靶机根目录index.php第一行就躺着一句话木马,谁 POST 个a参数都能执行任意 PHP 代码。
三、upload 目录一句话木马
漏洞类型
Webshell 后门 / 远程命令执行(RCE)
形成原因
upload目录里还藏了一个一句话木马,文件名是随机的(看着像 base64),POSTaaa就能 RCE。
复现(Burp Suite):
- 同样把请求丢进Repeater,请求行改成
POST /upload/0/file/202210/TYZMTYNTIxMzYMTAODcOQ.php HTTP/1.1; - Body 填
aaa=system('cat /flag');; - 点Go,响应里直接回显
/flag内容,跟上面 index.php 那个一样好使。
四、后台弱口令 admin/admin + 4 位数字验证码
漏洞类型
弱口令 / 验证码可爆破
形成原因
后台超级管理员是admin,密码存的是 MD5,而且就是MD5("admin")。验证码更坑:4 位纯数字(randstr(4,"num"),存 sessionLCMSPINCODE),输错还不重置——意味着对着同一张验证码能把 0000~9999 全爆一遍。
对手 14:59 那波就是这么干的:先用改后的密码登后台,验证码暴力过掉,然后走 upload 传了第二个 webshell。
原代码截图
源码
-- lcms_admin 表里的 admin 行INSERTINTO`lcms_admin`VALUES(1,0,1,'admin','超级管理员','21232f297a57a5a743894a0e4a801fc3',...);-- 21232f297a57a5a743894a0e4a801fc3 = MD5("admin")修改后的代码
改密码(比赛里fix_upload_attack.py先把被篡改的密码恢复成原强密码,赛后应该再设一个更复杂的):
UPDATElcms_adminSETpass='a7fa6b25198e06dbb5c40dae058bd283'WHEREname='admin';五、login docheck SQL 注入(登录绕过)
漏洞类型
SQL 注入(字符串拼接,admin 模块 filterform 完全不过滤)
形成原因
登录校验的docheck()里,查询是拿str_replace把:name占位符替换进 where 子句的,本质还是字符串拼接,name参数没做任何过滤。所以:
name=' OR '1'='1' --pass=1就能直接绕过去。而且 admin 模块的filterform完全不过滤,union select 都能回显,配合验证码爆破基本等于白给。
原代码截图
源码
publicfunctiondocheck(){global$_L,$CFGA;...$pass=md5($_L['form']['pass']);$admininfo=sql_get(["admin","(name = ':name' OR email = ':name' OR mobile = ':name') AND pass = '{$pass}'","id DESC",[":name"=>$_L['form']['name'],// ← 没过滤就直接替换进 SQL]]);修改后的代码
给name加了个addslashes(deploy_waf.py里顺手改的):
$pass=md5($_L['form']['pass']);$name=addslashes($_L['form']['name']);$admininfo=sql_get(["admin","(name = ':name' OR email = ':name' OR mobile = ':name') AND pass = '{$pass}'","id DESC",[":name"=>$name,]]);六、后台登录绕过(n=login 重复参数)
漏洞类型
认证绕过(参数解析不一致)
形成原因
这个藏得挺深。check_login()判断「是不是登录页」用的是HTTP_QUERY(整个 REQUEST_URI 的子串匹配),而真正要加载的模块名L_NAME是从$_GET['n']取的——PHP 对重复参数取的是最后一个。所以 URL 里先放个n=login骗过匹配,再放真实模块名,就能不登录直接进任意后台模块:
/admin/index.php?n=login&n=backup&c=database&a=index当时用这个思路去访问 backup / user / config 等模块,配合后台的注入点就能打。
原代码截图
源码
protectedfunctioncheck_login(){global$_L;$_L['LCMSADMIN']=SESSION::get("LCMSADMIN");$loginrootid=SESSION::get("LCMSLOGINROOTID");if(stristr(HTTP_QUERY,"n=login")===false&&!$_L['LCMSADMIN']){// ← 第 54 行:拿 REQUEST_URI 子串判断,能骗okinfo("{$_L['url']['admin']}index.php?rootid={$loginrootid}&n=login&go=".urlencode($_L['url']['now']));}elseif($_L['LCMSADMIN']){修改后的代码
改成只看真实加载的模块名L_NAME(fix_login_bypass.py),重复参数这招就废了:
if(L_NAME!="login"&&!$_L['LCMSADMIN']){// ← 只认真实模块名okinfo("{$_L['url']['admin']}index.php?rootid={$loginrootid}&n=login&go=".urlencode($_L['url']['now']));}elseif($_L['LCMSADMIN']){七、paycode(base64)/pay docheck SQL 注入
漏洞类型
SQL 注入(base64 编码参数绕过 WAF 和关键字过滤)
形成原因
这是被对手实打实利用过的点。前台pay入口的doindex()会base64_decode($_L['form']['paycode'])再json_decode,然后丢给PAYS::pay()。妙就妙在:base64 字符里不含任何 SQL 关键字,所以sqlinsert的关键字过滤和 WAF 全都被绕过了,解码后的order_no/payid再直接拼进 SQL。
对手就是拿这个点(配合 paycode 的cut/para这些 base64 参数)打的SET GLOBAL general_log写马——当然,后来确认他其实是直连 3306 干的,这个注入点更多是被当成了怀疑对象,但不管怎样它本身是实打实的洞。
原代码截图
源码
publicstaticfunctionorder_info($order_no){$order=sql_get(["order","order_no = '{$order_no}'"]);// ← 直接拼return$order;}publicstaticfunctionpayment_info($payid,$type=false){global$_L;$payment=LCMS::form(array("table"=>"payment","do"=>"get","id"=>$payid));// ← payid 没校验...}publicstaticfunctionorder($order=array()){...sql_update(["order",$order,"order_no = '{$order['order_no']}'"]);// ← 也拼...}publicstaticfunctionpay($paycode,$openid=""){$payment=self::payment_info($paycode['payid'],true);// ← payid 没 intval$payment?"":LCMS::X(404,"未找到支付参数");$order=$paycode['order_no']?self::order([...]):LCMS::X(404,"未找到订单信息");修改后的代码
order_no白名单[A-Za-z0-9]+、payid强制intval(fix_pays.py):
publicstaticfunctionorder_info($order_no){$order_no=preg_match('/^[A-Za-z0-9]+$/',$order_no)?$order_no:'';$order=sql_get(["order","order_no = '{$order_no}'"]);return$order;}publicstaticfunctionpayment_info($payid,$type=false){global$_L;$payid=intval($payid);$payment=LCMS::form(array("table"=>"payment","do"=>"get","id"=>$payid));...}publicstaticfunctionorder($order=array()){...$order['order_no']=preg_match('/^[A-Za-z0-9]+$/',$order['order_no'])?$order['order_no']:'';sql_update(["order",$order,"order_no = '{$order['order_no']}'"]);...}publicstaticfunctionpay($paycode,$openid=""){$paycode['payid']=intval($paycode['payid']);$paycode['order_no']=isset($paycode['order_no'])&&preg_match('/^[A-Za-z0-9]+$/',$paycode['order_no'])?$paycode['order_no']:'';$payment=self::payment_info($paycode['payid'],true);...}八、支付回调 notify SQL 注入(XML 绕过 WAF)
漏洞类型
SQL 注入(php://input/$_POST直达,绕过 filterform 和 WAF)
形成原因
4 个支付回调的notify.php都是独立入口,不用登录,$postObj直接取自php://input(XML)或$_POST,根本不走filterform过滤。这里最坑的是 wechat 的 XML:WAF 只查$_GET/$_POST/$_COOKIE,不查php://input,所以把注入 payload 塞进 XML 里就能绕过 WAF 直达 SQL。
当时用union select sleep(3),2,...,12 --做过时间盲注验证,lcms_order表 12 列,是能通的(报错注入因为 PDO SILENT 模式不回显,只能走时间盲注)。
原代码截图
源码
// wechat / wechat_agent —— WxPay.Notify.php(XML 注入,能绕过 WAF)$postObj=WxPayApi::FromXml(file_get_contents("php://input"));if($postObj['out_trade_no']){$order=sql_get(["order","order_no = '{$postObj['out_trade_no']}'"]);// ← 拼// alipay —— AliPay.Notify.php$postObj=$_POST;if($postObj['out_trade_no']){$order=sql_get(["order","order_no = '{$postObj['out_trade_no']}'"]);// ← 拼// codepay —— CodePay.Notify.php$postObj=$_POST;if($postObj['pay_id']&&$postObj['pay_no']){$order=sql_get(["order","order_no = '{$postObj['pay_id']}'"]);// ← 拼修改后的代码
out_trade_no/pay_id全加白名单[A-Za-z0-9]+(fix_notify.py):
// wechat —— WxPay.Notify.php$postObj=WxPayApi::FromXml(file_get_contents("php://input"));$postObj['out_trade_no']=isset($postObj['out_trade_no'])?preg_replace('/^[A-Za-z0-9]+$/','$0',$postObj['out_trade_no']):'';$postObj['out_trade_no']=preg_match('/^[A-Za-z0-9]+$/',$postObj['out_trade_no'])?$postObj['out_trade_no']:'';if($postObj['out_trade_no']){$order=sql_get(["order","order_no = '{$postObj['out_trade_no']}'"]);// wechat_agent —— WxPay.Notify.php$postObj=WxPayApi::FromXml(file_get_contents("php://input"));$postObj['out_trade_no']=preg_match('/^[A-Za-z0-9]+$/',$postObj['out_trade_no'])?$postObj['out_trade_no']:'';if($postObj['out_trade_no']){// alipay —— AliPay.Notify.php$postObj=$_POST;$postObj['out_trade_no']=preg_match('/^[A-Za-z0-9]+$/',$postObj['out_trade_no'])?$postObj['out_trade_no']:'';if($postObj['out_trade_no']){// codepay —— CodePay.Notify.php$postObj=$_POST;$postObj['pay_id']=preg_match('/^[A-Za-z0-9]+$/',$postObj['pay_id'])?$postObj['pay_id']:'';if($postObj['pay_id']&&$postObj['pay_no']){九、WAF 盲区:不检查php://input
漏洞类型
防护缺失(WAF 覆盖不到请求体原始流)
形成原因
WAF v2 是我们自己部署在core/route.php里的,只检查$_GET / $_POST / $_COOKIE,不读php://input。结果就是第八节那个 wechat XML 注入能从 WAF 眼皮底下溜过去——WAF 查不到,等于没查。
源码
<?phperror_reporting(E_ERROR|E_PARSE|E_CORE_ERROR|E_COMPILE_ERROR|E_USER_ERROR);date_default_timezone_set('Asia/Shanghai');/* ===== AWD WAF v2 START ===== */if(!defined('AWDBUGKU_WAF')){define('AWDBUGKU_WAF',1);functionawd_waf_block($t){header("HTTP/1.1 403 Forbidden");header("Status: 403 Forbidden");exit("Request blocked");}functionawd_waf_check($val){if(is_array($val)){foreach($valas$v){awd_waf_check($v);}return;}if(!is_string($val)||$val===''){return;}if(preg_match('/(\beval\s*\(|\bassert\s*\(|\bsystem\s*\(|\bpassthru\s*\(|\bshell_exec\s*\(|\bpopen\s*\(|\bproc_open\s*\(|`)/i',$val)){awd_waf_block('cmd');}if(preg_match('/(\bunion\b.+\bselect\b|\bsleep\s*\(|\bbenchmark\s*\(|\bextractvalue\s*\(|\bupdatexml\s*\(|\bload_file\s*\(|\boutfile\b|information_schema)/i',$val)){awd_waf_block('sqli');}if(preg_match('/((\bor\b|\band\b)\s*[\'\"]\s*[\'\"]?\s*\d+\s*=\s*\d+|(\bor\b|\band\b)\s*\d+\s*=\s*\d+|--\s|\/\*)/i',$val)){awd_waf_block('booli');}if(preg_match('/(\/etc\/passwd|\/flag\b|\.\.\/|\.\.\\\\)/i',$val)){awd_waf_block('path');}if(preg_match('/<\?(php|=)/i',$val)){awd_waf_block('shell');}}awd_waf_check($_GET);awd_waf_check($_POST);awd_waf_check($_COOKIE);// ← 漏了 php://input}/* ===== AWD WAF v2 END ===== */修改后的代码
WAF v3 补上php://input前 8KB 的检查(fix_waf_v3.py):
awd_waf_check($_GET);awd_waf_check($_POST);awd_waf_check($_COOKIE);$awd_raw=@file_get_contents("php://input",false,null,0,8192);// ← 兜底读原始请求体if(is_string($awd_raw)&&$awd_raw!==''){awd_waf_check($awd_raw);}}/* ===== AWD WAF v2 END ===== */