
해지 지속성 탐지 실습: 비밀번호 재설정은 성공했지만 공격자가 떠나지 않을 때. Strapi CVE-2026-22706 조건부 해지 버그와 그 수정, 세 가지 규칙 탐지 팩, 그리고 이를 놓치는 순진한 규칙을 재현합니다.
비밀번호 재설정은 성공했지만 공격자가 떠나지 않을 때.
하나의 취약점 클래스를 위한 로컬 레드/블루 랩: 그것을 죽였어야 했던 이벤트보다 더 오래 살아남는 자격 증명. 익스플로잇, 근본 원인, 수정, 세 가지 규칙 탐지 팩, 규칙이 구조적으로 볼 수 없는 것을 위한 상태 감사, 포렌식 콘솔 — 그리고 작동하지 않는 탐지 규칙까지 함께 제공되며, 실패하는 모습을 보여주기 위해 저장소에 보관됩니다.
빠른 시작 · 발견 사항 · 순진한 탐지가 실패하는 이유 · 규칙 팩 · 콘솔 · 수정 · 매트릭스 · 테스트 · 문서
동일한 공격. 동일한 요청. 단 하나의 차이.
콘솔이 그리는 것과 동일한 페이로드로 scripts/figures.py가 생성 — CI에서 재생성 및 diff되므로 그림이 코드와 어긋날 수 없습니다.
09:00 alice logs in ┐ refresh credential rt-001 │ the attacker steals rt-001 ┘
10:00 alice changes her password ← the one thing a victim can do alone HTTP 200 · password changed · fresh session issued
10:00:03 attacker: POST /refresh rt-002 → HTTP 200 new access credential at-004, issued 10:00:03
10:01:00 attacker: GET /me at-004 → HTTP 200 {"username": "alice", "authenticated": true}
오류 없음. 이상 징후 없음. 집계할 인증 실패 없음. 공격자의 접근 자격 증명은 *3초 전에 생성되었으며*, 리셋 이후 요청에 따라 서버가 발급한 것이다.
다음이 취약점의 전부이다:```python
def _revoke_for_security_change(self, user, kind, device_id):
if device_id:
revoke_credentials(user, device_id=device_id) # ← the finding
리뷰어의 시각으로 읽어보자. 폐기 로직은 바로 거기 있다. 올바른 스코프로 올바른 함수를 호출한다. 그 주변의 엔드포인트는 비밀번호 해시를 갱신하고, 200을 반환하며, 새 세션을 발급한다 — 올바른 비밀번호 변경의 모든 관찰 가능한 동작이 존재한다.
그리고 호출자가 device_id를 생략하면, 아무것도 폐기되지 않으며, 엔드포인트는 여전히 성공을 보고한다.
이것은 가상의 시나리오가 아니다. Strapi ≤ 5.33.2의
CVE-2026-22706으로,
리프레시 토큰 무효화 단계가 호출자가 제공한 deviceId에 조건부로 실행되었다.
2.1, Low로 평가되었다.
아래에서 논의한다.
점수가 낮은 이유는 공격자가 이미 접근 권한을 가지고 있었기 때문이다 — 그것이 진입 조건이며, 이 버그가 새로운 것을 부여하지는 않는다. 이 버그가 부여하는 것은 지속 시간이며, 피해자가 스스로 조작할 수 있는 유일한 통제 수단을 무력화함으로써 그렇게 한다.
test_persistence_lasts_as_long_as_the_refresh_credential은 7일의 시뮬레이션 시간을 걸어 이를 보여준다.억제 경로에서의 Low 점수 버그는 기능 경로에서의 Low 점수 버그보다 더 많은 비용을 초래한다. 그 비용은 사고 대응 중에 지불되며, 그때는 아무도 권고문을 읽지 않기 때문이다.
가장 먼저 작성하게 되는 규칙:```text IF credential.issued_at < credential_change.timestamp: ALERT
그것은 어리석은 규칙이 아니다. 비용이 저렴하고, 필드 하나만 필요하며, 문제의 정의처럼 읽히고, **단순한 경우에 대해서는 정확하다** — 재설정 이후 탈취한 *액세스* 토큰을 사용하는 공격자는 잡힌다.
`test_the_naive_rule_catches_the_simple_case`는 이것이 작동함을 검증한다.
두 규칙 모두 동일한 자격 증명에 대해 같은 질문을 던진다. 답을 얻기 위해 서로 다른 필드를 읽으며, 두 필드는 서로 모순된다:

**3초 후, 아니면 한 시간 전 — 같은 순간의 같은 자격 증명.** 순진한 규칙은 공격자가 제어하는 필드를 묻고, `POST /refresh` 한 번이면 그것이 재설정된다.
이 쿼리가 실제로 작성되는 곳인 요청 로그에 대해 실행해 보라. 요청 로그야말로 당신이 가진 것이기 때문이다:```text
NAIVE DETECTOR (NAIVE-001), over the request log
Result: NO ALERT
Six requests were served to the attacker after the reset. Every one of them
carried at-004, minted at 10:00:03 -- three seconds *after* the password
change. By its own timestamp it is the newest credential on the account.
거기서 멈추는 것은 쉬울 뿐만 아니라 부정직한 일이므로, 이 랩은 가장 공정한 버전의 순진한 규칙도 실행합니다 — refresh 엔드포인트까지 감시하도록 확장한 버전입니다:```text NAIVE DETECTOR, widened to include POST /refresh
1 alert at 2026-09-11T10:00:03.000Z: the credential presented to /refresh was rt-002, issued 2026-09-11T09:30:00.000Z. Then it goes blind. 6 events follow that hop and it flags none of them, because every credential from there on carries a post-reset timestamp. Its incident covers 1 accepted request; the lineage rule's covers all of them.
And look at what the alert names: NAIVE-001 revoke rt-002 -- rotated away and already dead at 10:00:03 AFTERLIFE-001 revoke lin-001 -- the live thing every future credential descends from
3시에 중요한 차이는 바로 이것이다. 순진한 규칙은 체인이 경계를 넘는 단 한 순간을 포착한 뒤 남은 29일 동안 흔적을 놓친다 — 그리고 그것이 지목하는 자격 증명은 *이미 서버에 의해 회전되고 폐기된 상태*다. 그것을 폐기하는 것은 아무것도 달성하지 못한다. `lin-001`이 바로 네가 죽여야 하는 객체다.
**그리고 순진한 규칙은 텔레메트리가 부족했던 것이 아니다.** 그것은 동일한 이벤트 스트림, 동일한 계보 인덱스, 동일한 허용 오차, 동일한 중복 제거, 그리고 동일한 경계 상태를 사용한다. 그것은 단 하나의 메서드를 재정의한다:```python
class Correlator: # AFTERLIFE-001
def _age_reference(self, facts):
return facts.root_issued_at
class NaiveCorrelator(Correlator): # NAIVE-001
def _age_reference(self, facts):
return facts.issued_at
root_issued_at은 이미 참조 중인 인덱스에 들어 있습니다.
test_the_naive_rule_had_the_data_it_needed가 이를 증명합니다. 실패는
비교에 있지, 로깅에 있지 않습니다.
하나의 로그 위에 세 가지 규칙. 각각 다른 질문에 답하며, 사고가 실제로 전개되는 순서대로 발동합니다.
| 규칙 | 심각도 | 답하는 질문 | 발동 시점 | |
|---|---|---|---|---|
| AFTERLIFE-002 | Refresh credential reuse | CRITICAL / MEDIUM | 탈취되었는가? | 재사용 시점 |
| AFTERLIFE-003 | Incomplete revocation at a security change | HIGH / LOW | 격리가 실행되었는가? | 변경 시점 — 공격자 불필요 |
| AFTERLIFE-001 | Post-revocation credential lineage use | HIGH | 오래된 계보가 사용되었는가? | 최초 수용 시점 |
| RULE PACK |
10:00:00 HIGH AFTERLIFE-003 Incomplete revocation at a security change 10:00:03 HIGH AFTERLIFE-001 Post-revocation credential lineage use
**그 순서가 이 저장소에서 가장 유용한 부분이다.** AFTERLIFE-003은 변경이 적용되는 순간, 공격자가 무엇이든 건드리기 3초 전에 발동한다. 왜냐하면 그것이 필요로 하는 증거는 이미 완전하기 때문이다. 로그는 어떤 계보가 진입 시점에 살아 있었는지 말해주며, 그것들이 폐기되었다고는 말하지 않는다.
피해자도, 익스플로잇도 필요하지 않다. 그것은 사용자가 수행하는 첫 번째 비밀번호 재설정에서 결함을 보고할 것이다 — 이는 기다릴 공격자가 없는 스테이징에서 실행할 수 있는 유일한 규칙이 된다. AFTERLIFE-001은 침해가 진행 중임을 알려주고, AFTERLIFE-003은 당신의 격리 통제가 깨졌음을 알려준다.

`application recorded watermark: no`는 사고 대응자를 증상이 아니라 *결함*으로 향하게 하는 필드이다.
### 규칙이 사용하는 워터마크는 애플리케이션이 보고하는 것과 다르다