[HFCTF2020]EasyLogin
文章目录
- TRY
- WP
- 总结
TRY
注册admin报错username wrong。
随便注册一个用户点击GetFlag,permission deny。
猜测可能是需要admin权限。
看cookie发现有:
sses:aok:eyJ1c2VybmFtZSI6ImEiLCJfZXhwaXJlIjoxNzU2NDU1NjczMTAxLCJfbWF4QWdlIjo4NjQwMDAwMH0=
sses:aok.sig:cPcLr5TdZHkihzRoMmGXTwP_0wM
sses:aok可以base64解码:{“username”:“a”,“_expire”:1756455673101,“_maxAge”:86400000}
第二个参数表示失效时间,第三个参数表示最大存活时长。sses:aok.sig应该就是签名。
这种 sses:aok + sses:aok.sig 的组合,是 **“自定义令牌 + 签名验证” 的轻量化认证方案 **,核心逻辑是 “用 Base64 编码携带身份与有效期信息,用独立签名确保完整性与合法性”。
签名的编码方式不清楚,AI说是对Hash编码的二进制数进行Base64url编码。
解题方向就两个,第一,登录admin账户;第二:爆破签名,伪造cookie认证。
尝试约束攻击登录admin账户,不可行。
WP
漏掉了一些线索。
从浏览器控制台中的调试中能看到前端代码
app.js中留下线索:
/**
- 或许该用 koa-static 来处理静态文件
- 路径该怎么配置?不管了先填个根目录XD
*/
在 Koa.js(Node.js 的一个 Web 框架)中,koa-static 是一个常用的中间件(middleware),用于处理静态文件的访问请求。
简单来说,它能让服务器直接返回指定目录中的静态资源(如 HTML、CSS、JavaScript、图片、字体等),而无需开发者编写额外代码来读取和返回这些文件。具体功能:
koa-static 的主要功能是:
指定静态文件目录:告诉 Koa 服务器 “某个目录下的文件是静态资源,可以直接对外提供访问”。
自动映射请求路径:当客户端请求某个路径时(如 /css/style.css),koa-static 会自动去你指定的静态目录中查找对应的文件(如 ./public/css/style.css),并返回给客户端。
处理 MIME 类型:自动识别文件类型(如 .html、.js、.png),并在响应头中添加正确的 Content-Type,确保浏览器能正确解析文件。
作用就像是Golang中的HTTP.FileServer。根据线索提示将根目录指定为静态文件目录,那源码就成了可访问文件了。
于是直接访问/app.js得到Web 应用程序入口文件:
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}...`);
具体业务逻辑处理在controllers文件夹中。但是并不知道文件名。
根据wp,源码文件是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();}
};
代码中有几个重要的地方:
- 注册的时候会生成global [secretid=>secret],secretid存储在jwt的payload中。
- 登陆的时候根据secretid取出secret进行签名验证。
const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;
在取出secretid时没有验证签名,我们可以伪造。 if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) { throw new APIError('login error', 'no such secret id'); }
对secretid进行了过滤。const user = jwt.verify(token, secret, {algorithm: 'HS256'});
在验证token的时候指定了签名算法’HS256’,似乎无法通过签名算法置none绕过。
secretid可伪造 + secretid过滤不彻底 + Node.js 的jsonwebtoken库低版本存在缺陷:即使指定了验证签名时的算法,如果secret为空,会默认用none来验证
以上三个条件就导致了本题中的漏洞:首先我们伪造secretid为[]即空数组,此时能通过过滤,并且global.secrets[secretid]得到的应该是undefined或者是null,我也没有验证过。验证签名时就会用none算法,我们只需要构造签名算法为none的token就能通过验证。
构造payload:
成功登录。直接就能获取flag了。
总结
这道题首先从前端JS代码中获得线索,koa-static处理静态文件时将工作区根目录指定为静态文件目录,这就导致了工作区所有源码文件泄露。但是api.js文件名来的就有些奇怪,或许这是Node.js项目中的常规命名?得到源码后能看到使用的是JWT认证,由于多个条件组合导致了JWT伪造漏洞。漏洞修改建议:对于客户端传输的token,应该遵守 在验证其正确性之后才能从中获得任何信息 的规范,避免用户伪造。用白名单限定secretid的数据类型而不是黑名单。升级jwt模块版本。另外有一个注意的点是,浏览器控制台中的网络监视器并不能捕获所有发送到服务端的请求,例如这道题,由此我漏掉了重要线索,所以下次还是要用bp。