Project/CTF 문제 만들기

CVE-2020-7245 CTFd Account Takeover 기반 CTF 문제 만들기

minpo 2024. 9. 16. 05:28

아래에서 github에서 CVE-2020-7245 CTFd Account Takeover 다운받아서 사용가능합니다.

아래 링크는 CVE를 분석한 글입니다.

https://minpo.tistory.com/47

 

CVE-2020-7245: CTFd Account Takeover

최근 GRAPE 해킹 동아리에서 CTF문제 사이트를 공개하였다. CTF 문제사이트를 위한 CTF-D라는 라이브리를 사용하였다. 이에 CTF-D에 취약점이 존재하였는지 확인하다. 간단하지만 재미있는 CVE를 찾게

minpo.tistory.com

 

https://github.com/mingijunggrape/make_CTF/tree/main

 

GitHub - mingijunggrape/make_CTF

Contribute to mingijunggrape/make_CTF development by creating an account on GitHub.

github.com

 

dockerfile 사용법

docker build -t flask-app .
docker run -p 5000:5000 flask-app

 

이후 127.0.0.1:5000 포트로 접속 가능합니다.

문제 의도 :

  • 로지컬한 버그를 찾을 수 있는가에 대한 문제입니다
  • 이를 바탕으로 mysql 및 flask에 친숙해 질 수 있습니다.

아래부터는 문제 풀이입니다.

일단 admin 과 flag는 아이디와 비밀번호로 설정되어 있습니다. 또한, mysql 버전은 5.5 버전입니다.

또한, 로그인 창, 회원가입 창, 로그인 했을 경우 보여지는 창, 비밀번호 변경 창이 존재합니다.

저희는 admin의 비밀번호를 구하여야 합니다.

 

아래는 회원가입 서버 코드입니다. 문제의 기반이 된 CVE-2020-7245 에서는 strip()을 통해서 아이디와 비밀번호를 필터링하지 않았고 white-space의 차이점이 발생하게 되어 이미 존재하는 아이디를 새로 생성하는 것이 가능했고(엄밀히 말하면 같은 것은 아니고 비슷한 아이디이다. white-space 차이가 존재하기 때문이다) fiter_by는 공백을 무시하기 때문에 원래 존재하던 admin의 비밀번호를 변경하는 것이 가능하였다.

 

하지만 아래 코드에서 보면 strip은 존재한다. 그렇기 때문에 mysql의 5.6 이하는 mysql strict 모드가 비활성화 된다는 점을 노려야 한다.

def register():
    if request.method == "POST":
        useid = request.form.get("username").strip()
        usepw = request.form.get("password").strip()

        existing_user = User.query.filter_by(username=useid).first()
        if existing_user:
            flash('Username already exists. Please choose a different one.', 'error')
            return render_template('register.html')

        new_user = User(username=useid, password=usepw)
        db.session.add(new_user)
        db.session.commit()

        flash('Register successful! You can now log in.', 'success')
        return redirect(url_for('login'))  
    else:
        return render_template('register.html')

 

strict 모드가 비활성화 되면, 주어진 고정 길이를 넘어가면 오류를 발생 시키는 것이 아니라 고정 길이만큼 짜른 이후 넣는다.

 

우리의 데이터베이스는 아래와 같이 구성되어 있다.

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.CHAR(20), nullable=False)  
    password = db.Column(db.CHAR(120), nullable=False)  

    def __repr__(self):
        return f'<user {self.username}>

 

즉 strip의 특징과 strict 모드의 비활성화를 응용하면 된다.

 

strip은 leading, trailing space(앞, 뒤 공백)을 테스트 한 결과이다. trailing space만 제거된다고 설명했다. 하지만 추가적으로 middle space 또한 제거되지 않는다. 즉 admin(space)a(space)를 넣는 경우 strip은 admin(space)a를 결과값으로 반환한다. 이를 통하여 아래와 같이 로지컬 버그를 만들 수 있다.

  • admin\s{20}a 를 아이디로 넣게 되면 stript을 해도 결과는 admin\s{20}a이다
  • 이후 fiter_by 함수에서 admin ≠admin\s{20}a 결과가 나올 것이다.
  • 그리고 난 뒤에, admin\s{20}a 를 데이타베이스에 입력할 것이다.
  • 이때, 20개의 문자열 때문에 admin\s{15} 가 들어갈 것 이다.
  • 이후 우리는 admin\s{15} 을 통해서 로그인한다.

이후 비밀번호 변경에 들어가면 flag가 나오는데, 이는 세션에 있는 우리의 값은 admin\s{15} 이고 이를 통해서 fiter_by를 한다. 이때 공백이 무시됨으로 admin을 찾고 거기에 맞는 비밀번호를 보여주게 된다.

아래 소스코드

@app.route('/change/')
def Change():
    user = User.query.filter_by(username=session["userID"]).first()
    return render_template('change.html', password = user.password)

여담

문제를 만들면서 몇 가지 알게 된 사실이 존재한다.

일단 char과 varchar의 차이점이다. 고정길이를 통해서 짜르는 것은 같다. 하지만 고정길이를 공백으로 채우냐 마냐가 다르다. char의 경우 고정길이만큼의 값이 안들어 온 경우 모두 space로 찾이한다. 하지만, varchar의 경우 다르다. 결론적으로 위 문제에서 admin 및 flag도 실질적으로 admin ~공백~~ 및 flag공백 인 상태인 것이다. fiter_by가 공백을 무시하지 않더라도 로지컬 버그가 발생한다. varchar을 사용한 경우 fiter_by의 공백 무시가 필수적이다.

 

또한 몇 가지 삽질을 하였다. 처음에 환경구축의 편의성을 위해서 DBMS를 sqlite3으로 구축하였다. 하지만 sqlite에서 char,varchar,text를 방식으로 만들라고 한다면 모두 text로 만들어진다. 즉, 고정길이를 설정할 수 없다.

 

물론 text 또한 max_length가 존재한다. 65535 byte이다. 그러기 때문에 admin\s{65535}a를 넣게 된다면 짤린다. text 또한 varchar처럼 바이트 할당이 유동적이여서 공백의 차이가 발생하지만, fiter_by가 공백을 무시하기 때문에 상관없다. 하지만 문제를 만들때 admin\s{65535}a를 넣으라고 하는 것은 조금 그래서 mysql로 환경을 구축하고 다시 코드를 작성했다.