2026-10-18(UTC)에 저장소 <작업 폴더>/plug2(브랜치 main, 커밋 bfbbd42) 전체를 medium 강도로 스캔했습니다. 범위 제한은 없었습니다. 검증을 통과한 발견은 8건입니다. HIGH 2건, MEDIUM 3건, LOW 3건이고 CRITICAL은 없습니다. 가장 시급한 문제는 두 가지입니다. 하나는 로그인 없이 쓸 수 있는 /search의 SQL 인젝션으로, DB 전체를 읽고 모든 계정을 탈취할 수 있습니다. 다른 하나는 /receipt의 경로 조작으로, DB 파일과 설정 파일을 그대로 내려받을 수 있습니다.
app 구성 요소(app/, receipts/) 한 개입니다. 추적 파일은 모두 11개이고, 파일 약 25개당 구성 요소 하나를 잡는 기준(최대 24개)에 따라 구성 요소 1개로 나뉘었습니다.app의 인젝션·입력 처리app의 인증·인가app의 암호화·비밀값app/__init__.py, app/config.py, app/db.py, app/server.py, app/views.py, receipts/1001.txt 6개를 끝까지 읽었다고 보고했습니다. 읽지 못했다고 밝힌 파일이나 누구도 맡지 않은 파일은 없습니다.tests/입니다. 사유는 "Unit tests and sample guest fixture data; not shipped product code"(단위 테스트와 샘플 손님 픽스처 데이터로, 배포되는 제품 코드가 아님)입니다. 구성 요소 밖 파일 5개(tests/ 3개, 루트의 .gitignore, README.md)는 위의 "구성 요소 밖 파일" 조사자가 따로 맡았습니다.app:memory-and-unsafe): app 구성 요소 조사자에게는 메모리 안전성 버그를 묻지 않았습니다. 인벤토리가 파악한 언어만 보고, 조사자에게 작업을 맡기기 전에 이 코드가 모두 관리형 언어라고 판단해 이 관점을 뺐기 때문입니다.app, receipts, tests)가 모두 "스캔함" 또는 "명시적으로 제외함"으로 분류됐습니다. 트리 전체가 빠짐없이 다뤄졌다는 뜻입니다./search의 인증 없는 SQL 인젝션으로 모든 손님의 세션 토큰과 비밀번호 해시 노출 (HIGH, confidence high)Impact. 서버에 접근할 수 있는 사람이면 누구든 DB 전체를 읽을 수 있습니다. 모든 손님의 전화번호, 만료되지 않는 세션 토큰, 솔트 없는 MD5 비밀번호 해시, 전체 예약이 노출됩니다. 탈취한 토큰으로 어떤 계정이든 가로챌 수 있습니다.
Where. app/db.py:69 in search_by_name — CWE-89
What. 로그인 없이 호출되는 GET /search(app/server.py:46)는 쿼리 파라미터 name을 받습니다. 이 값이 f-string으로 SQLite 문장에 바로 들어가고 바인딩 파라미터 없이 실행되므로, 공격자가 SQL을 마음대로 조작할 수 있습니다. 중간에 이스케이프나 검증은 없습니다.
Exploit scenario. 로그인 없이 GET /search?name=%25'%20UNION%20SELECT%201,phone,token,0%20FROM%20guests--를 요청합니다. 쿼리는 ... LIKE '%%' UNION SELECT 1,phone,token,0 FROM guests--%'가 됩니다. 그러면 reservation_card가 이름 자리에 각 손님의 전화번호를, 방문 시각 자리에 bearer 토큰을 출력합니다. 이 토큰을 X-Guest-Token에 넣어 보내면 해당 손님 계정을 그대로 쓸 수 있습니다. token을 pw_hash로 바꾸면 MD5 해시가 나옵니다. sqlite3는 여러 문장을 이어 실행하지 않지만, UNION이나 불리언·시간 기반 추출로 어떤 테이블이든 읽을 수 있습니다.
Preconditions.
- 공격자가 HTTP 서버에 접근할 수 있어야 합니다. config.HOST가 127.0.0.1에 바인딩되어 있으므로, 실제로는 리버스 프록시를 통하거나 로컬 머신에서 접근하는 경우입니다.
Fix. 바인딩 파라미터를 사용합니다. 예: c.execute("... WHERE guest_name LIKE ?", (f"%{name}%",)). %와 _를 글자 그대로 찾게 하려면 입력에서 이스케이프합니다. /search에 인증을 요구하는 것도 함께 검토하세요(F4).
Verification. 3/3 lens verifiers confirmed.
/receipt 경로 조작으로 가입한 손님 누구나 서버가 읽을 수 있는 모든 파일(DB, 설정) 열람 (HIGH, confidence high)Impact. 서버 프로세스가 읽을 수 있는 파일은 모두 읽힙니다. DB 전체(모든 손님 계정 탈취로 이어짐), 하드코딩된 관리자 비밀번호가 든 설정 파일, 소스 코드, 시스템 파일이 여기에 포함됩니다.
Where. app/server.py:54 in Handler.do_GET — CWE-22
What. 쿼리 파라미터 file이 os.path.join으로 RECEIPT_DIR 뒤에 붙은 뒤(app/server.py:52) 그대로 열립니다. basename이나 realpath 검사가 없어서 ../나 절대 경로로 receipts 디렉터리를 벗어날 수 있습니다.
Exploit scenario. 공격자는 먼저 아무 전화번호와 비밀번호로 POST /register를 보냅니다. 가입이 열려 있어 토큰이 바로 발급됩니다. 이 토큰을 X-Guest-Token에 넣고 다음을 요청합니다.
- GET /receipt?file=../cafe.db: SQLite DB를 통째로 내려받아 모든 토큰과 해시를 얻습니다.
- ?file=../app/config.py: 관리자 비밀번호를 얻습니다.
- ?file=/etc/passwd: 두 번째 인자가 절대 경로이면 os.path.join이 RECEIPT_DIR를 버리므로 절대 경로도 통합니다.
Preconditions.
- 손님 토큰이 필요하지만, 공개된 POST /register로 누구나 받을 수 있습니다.
- HTTP 서버에 접근할 수 있어야 합니다(127.0.0.1 바인딩이므로 실제로는 리버스 프록시를 통하는 경우).
Fix. 입력을 ^\d+\.txt$ 같은 엄격한 패턴으로 검사합니다. 경로를 os.path.realpath로 풀어서 realpath(RECEIPT_DIR) 밖이면 거부합니다. 영수증을 요청한 손님의 예약과 연결해 확인합니다(F6).
Verification. 3/3 lens verifiers confirmed.
Impact. 관리자 브라우저 세션에서 공격자의 스크립트가 실행됩니다. 관리자 비밀번호가 URL에 들어 있어 탈취될 수 있고, 관리자 화면을 읽거나 바꿀 수도 있습니다.
Where. app/views.py:22 in admin_table — CWE-79
What. POST /reserve의 note 필드는 스스로 가입한 손님 누구나 보낼 수 있습니다. 이 값이 그대로 저장된 뒤 text/html인 /admin 페이지에 html.escape 없이 들어갑니다. 다른 열은 모두 이스케이프됩니다.
Exploit scenario.
1. 공격자가 가입한 뒤 POST /reserve를 보냅니다. visit_time은 오늘 날짜로 넣어 list_today의 date(visit_time)=date('now') 조건을 통과시킵니다. note에는 location.href를 외부로 보내는 <script> 태그를 넣습니다.
2. 사장님이 /admin?pw=...를 열면 스크립트가 앱 출처에서 실행되어 URL을 공격자에게 보냅니다.
3. 쿼리 문자열에 관리자 비밀번호가 있으므로 공격자가 비밀번호를 얻습니다.
Preconditions.
- 공격자가 가입하고(누구나 가능) 오늘 날짜로 예약을 만들어야 합니다.
- 그 예약이 목록에 있는 동안 관리자가 /admin을 열어야 합니다.
Fix. 다른 열처럼 메모도 html.escape(r['note'] or '')로 이스케이프합니다. 엄격한 Content-Security-Policy를 추가하고, 관리자 비밀번호를 URL로 전달하지 않도록 바꿉니다.
Verification. 3/3 lens verifiers confirmed.
/search가 인증 없이 모든 손님의 예약을 반환함 (MEDIUM, confidence high)Impact. 누구나 인증 없이 예약 장부 전체를 읽을 수 있습니다. 이 엔드포인트는 F1의 SQL 인젝션 지점도 익명 사용자에게 열어 둡니다.
Where. app/server.py:45 in Handler.do_GET — CWE-306
What. /search 경로는 _guest()나 다른 인증 검사를 거치지 않고 name을 db.search_by_name에 넘깁니다. name이 없거나 비어 있으면 LIKE '%%'가 되어 모든 행과 일치합니다.
Exploit scenario. 자격 증명 없이 GET /search(또는 /search?name=)를 보내면, 누가 언제 몇 명으로 오는지 DB의 모든 예약이 HTML 목록으로 돌아옵니다.
Preconditions. - HTTP 서버에 접근할 수 있어야 합니다(127.0.0.1 바인딩이므로 실제로는 로컬 접근이나 리버스 프록시를 통하는 경우).
Fix. /search에서 self._guest()를 요구하고 결과를 호출한 사람의 예약(WHERE guest_id=?)으로 제한합니다. 필요 없다면 엔드포인트를 없앱니다. LIKE 쿼리의 파라미터화는 F1에서 따로 처리합니다.
Verification. 3/3 lens verifiers confirmed.
Impact. 손님 비밀번호가 한꺼번에 노출될 수 있습니다. 사람들은 비밀번호를 재사용하기 때문에 피해가 이 앱 밖으로도 번집니다.
Where. app/db.py:29 in hash_pw — CWE-916
What. POST /register로 받은 비밀번호가 솔트 없는 MD5로 저장됩니다(app/db.py:35). guests 테이블을 읽을 수 있으면 GPU 무차별 대입이나 레인보우 테이블로 깰 수 있습니다. 이 테이블은 인증 없는 /search SQL 인젝션(F1)이나, 가입한 손님 누구나 쓸 수 있는 /receipt?file=../cafe.db(F2)로 읽을 수 있습니다.
Exploit scenario. GET /search?name=' UNION SELECT id, phone, pw_hash, 1 FROM guests --로 모든 손님의 전화번호와 MD5 해시를 얻습니다. 솔트가 없어서 같은 비밀번호는 같은 해시가 됩니다. 그래서 미리 계산된 테이블과 GPU 크래킹으로 실제 비밀번호 대부분을 몇 분 안에 알아낼 수 있고, 이를 손님의 다른 서비스 계정에 시도할 수 있습니다.
Preconditions.
- guests 테이블을 읽을 수 있어야 합니다(F1, F2, 백업이나 DB 파일 접근 중 하나).
- 서버에 네트워크로 접근할 수 있어야 합니다(127.0.0.1 바인딩).
Fix. argon2id, bcrypt, scrypt 같은 메모리 집약적이고 솔트를 쓰는 KDF로 바꿉니다. 예: 사용자별 무작위 솔트를 쓰는 hashlib.scrypt. 검증은 라이브러리의 verify 함수나 hmac.compare_digest로 합니다. 기존 사용자는 다음 로그인 때 다시 해시합니다.
Verification. 3/3 lens verifiers confirmed.
/receipt가 영수증 소유자를 확인하지 않고 어느 손님에게나 제공함 (LOW, confidence high)Impact. 손님 누구나 다른 고객의 구매 영수증을 읽을 수 있습니다. F2의 경로 조작과 합치면 서버가 읽을 수 있는 모든 파일이 노출됩니다.
Where. app/server.py:52 in Handler.do_GET — CWE-639
What. 어떤 영수증을 줄지는 클라이언트가 보낸 file 파라미터가 정합니다. 확인하는 것은 손님 토큰이 있는지뿐이고, 영수증을 호출자의 손님 id나 예약과 연결하는 절차가 없습니다. 영수증 이름도 receipts/1001.txt처럼 예측 가능한 숫자입니다.
Exploit scenario. 공격자가 스스로 가입한 뒤 자기 토큰으로 GET /receipt?file=1001.txt, 1002.txt, ...를 차례로 요청해 모든 고객의 영수증을 내려받습니다.
Preconditions. - HTTP 서버에 접근할 수 있어야 합니다(127.0.0.1 바인딩). - 아무 손님 토큰이나 있으면 되는데, 공개 가입으로 얻을 수 있습니다.
Fix. 영수증을 예약 id로 조회합니다. 그 예약의 guest_id가 호출자 id와 같은지 확인하고, 파일명은 클라이언트 입력이 아니라 서버가 그 id로 만듭니다. realpath로 RECEIPT_DIR 안에 있는지도 검사합니다.
Verification. 3/3 lens verifiers confirmed. 연구자는 MEDIUM으로 보고했지만, 확인한 검증자들의 평가(MEDIUM, LOW, LOW)에 따라 LOW로 낮춰졌습니다.
/reservation IDOR: 로그인한 손님 누구나 id로 아무 예약이나 조회 가능 (LOW, confidence medium)Impact. 손님 누구나 모든 예약을 읽을 수 있습니다. 다른 고객의 이름, 방문 시각, 인원이 노출됩니다.
Where. app/server.py:40 in Handler.do_GET — CWE-639
What. 클라이언트가 보낸 id가 db.get_reservation(app/db.py:63, SELECT * FROM reservations WHERE id=?)으로 바로 들어갑니다. _guest()가 돌려준 손님 정보는 있는지만 확인하고, 행의 guest_id와는 비교하지 않습니다. README가 이 페이지를 '내 예약'이라고 부르는 것으로 보아 본래 소유자만 보게 할 의도였습니다.
Exploit scenario. 공격자가 POST /register로 토큰을 받은 뒤 X-Guest-Token을 넣고 GET /reservation?id=1, 2, 3...을 요청합니다. id가 순차 INTEGER PRIMARY KEY라서 다른 고객 예약을 전부 차례로 볼 수 있습니다.
Preconditions. - HTTP 서버에 접근할 수 있어야 합니다(127.0.0.1 바인딩). - 아무 손님 토큰이나 있으면 되는데, 공개 가입으로 얻을 수 있습니다.
Fix. 호출자 범위 안에서만 조회합니다. 예: SELECT * FROM reservations WHERE id=? AND guest_id=?에 g['id']를 넘기고, 일치하지 않으면 404를 반환합니다.
Verification. 2/3 lens verifiers confirmed. 연구자는 MEDIUM으로 보고했지만, 확인한 검증자들의 평가(LOW, LOW)에 따라 LOW로 낮춰졌습니다.
/register 응답으로 이미 가입된 전화번호를 알아낼 수 있음 (LOW, confidence medium)Impact. 어떤 전화번호가 가입 고객인지 드러납니다. 번호 소유를 확인하지 않으므로, 아직 가입하지 않은 번호를 공격자가 먼저 선점할 수도 있습니다.
Where. app/server.py:71 in Handler.do_POST — CWE-204
What. 공격자가 고른 전화번호가 UNIQUE 열에 들어갑니다(app/db.py:35). 이미 있는 번호면 sqlite3가 IntegrityError를 일으키는데, 이를 잡는 코드가 없어 HTTP 응답 없이 연결이 끊깁니다. 새 번호면 200과 토큰이 돌아오므로 두 경우를 쉽게 구별할 수 있습니다.
Exploit scenario. 공격자가 여러 전화번호로 POST /register phone=<대상>&pw=x를 보냅니다. 연결이 끊기면 가입 고객이고, 200이면 미가입 번호입니다(이 경우 그 번호의 계정을 공격자가 갖게 됩니다). 확인된 번호는 속도 제한이 없는 /login에서 비밀번호 추측 대상이 됩니다. 연결이 끊기는 동작은 http.server/socketserver 코드 경로를 읽고 추론한 것이며, 실제로 실행해 확인하지는 않았습니다.
Preconditions. - HTTP 서버에 접근할 수 있어야 합니다(127.0.0.1 바인딩).
Fix. IntegrityError를 잡아서 번호가 있든 없든 같은 응답을 돌려줍니다. 계정을 활성화하기 전에 SMS 인증 등으로 번호 소유를 확인하고, /register와 /login에 속도 제한을 둡니다.
Verification. 3/3 lens verifiers confirmed.
진행 순서는 다음과 같습니다.
1. 인벤토리가 저장소를 구성 요소로 나눴습니다.
2. 위협 모델을 세웠습니다.
3. 조사자들이 취약점 유형별(인젝션·입력 처리, 인증·인가, 암호화·비밀값)로 app 구성 요소를 읽었고, 별도 조사자가 구성 요소 밖 파일 전체를 훑었습니다. 4명 모두 결과를 돌려줬습니다.
4. 후보 발견마다 서로 다른 관점(도달 가능성, 영향, 방어 수단)의 검증자 3명이 표결했고, 3명 중 2명 이상이 확인한 것만 남겼습니다.
F7은 2/3, 나머지 7건은 3/3 만장일치로 확인됐습니다. F6과 F7은 패널 평가에 따라 심각도가 MEDIUM에서 LOW로 낮아졌습니다. 모든 발견은 코드를 읽고 판단한 것입니다. 저장소 코드를 실행하거나 테스트를 돌리거나 익스플로잇을 실제로 시도하지는 않았습니다. 이 보고서의 검증 상태는 보고서 생성기가 투표 기록에서 직접 계산해 스탬프 파일에 기록합니다.