CVE-2026-41429

CVE-2026-41429

Area
Native / Embedded
Credit
Public Attribution
Credit Note
Official GHSA public credit: @Amemoyoi — Reporter. Portfolio author: Taegu Ha.
CVSS
8.8
CVSS Source
CNA / Vendor
CVSS Vector
CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE
CWE-121
Impact
Memory Corruption
Key Finding
UDP/137의 attacker-controlled name_len wire byte가 검증 없이 source traversal과 fixed stack response copy에 사용돼 OOB read/write가 발생했습니다.
Published
Apr 24, 2026
Score Note
GitHub CNA 8.8. C:H/I:H/A:H는 공식 scoring scenario이며, 공개 host harness는 OOB read/write를 확인했지만 모든 device-level 영향을 독립 입증하지는 않습니다.
Severity
High
Status
Public
Target
arduino-esp32 < 3.3.8 · libraries/NetBIOS
 

TL;DR

page icon
UDP/137로 받은 NBNS packet의 name_len wire byte가 검증 없이 고정 크기 stack buffer 처리에 사용됐습니다. 분리된 host C harness로 source-side OOB read와 응답 memcpy()의 stack OOB write를 각각 확인했습니다.
Portfolio author Taegu Ha · 공식 GHSA reporter credit @Amemoyoi · arduino-esp32 · CWE-121 · GitHub CNA CVSS 8.8 High
GHSA 공개 2026-04-16 · CVE record 공개 2026-04-24 · 공식 영향 < 3.3.8 · 수정 3.3.8 · 공식 advisory
항목
내용
상태
PUBLISHED
제품·컴포넌트
espressif/arduino-esp32 · libraries/NetBIOS
외부 입력
NBNS가 활성화된 장치의 UDP/137 request
공격자
같은 네트워크의 비인증 사용자
취약점
source-side OOB read와 fixed stack-destination OOB write
공식 버전 범위
affected < 3.3.8 · fixed 3.3.8
조사 snapshot
c1f40e35ca131871d961089505487ebe1388da33
CWE·CVSS
CWE-121 · 8.8 High · CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
검증 경계
최소 host C harness로 두 memory primitive 확인; 실장치 crash·RCE·3.3.8 runtime regression은 미검증

공격 흐름


취약점 개요

arduino-esp32에서 NBNS.begin()을 호출하면 장치가 UDP 137번에서 NBNS 요청을 처리합니다. _onPacket()은 packet data를 nbns_question_t로 직접 해석하므로 question->name_len은 공격자가 packet에서 지정하고 parser가 그대로 읽는 1-byte wire field입니다. 취약 코드는 이 값이 고정 크기 buffer와 일치하는지 확인하지 않았습니다.
name_len과 뒤따르는 encoded name bytes의 조합에 따라 _getnbname()이 source 경계를 넘어 읽을 수 있습니다. 별도로 정상적인 대상 이름 인코딩과 decode terminator로 _name.equals(name)을 통과시킨 뒤에도 oversized name_len은 유지됩니다. 이후 memcpy(..., name_len + 1)가 33-byte nbnsa.name을 넘어 쓸 수 있습니다.
대상은 arduino-esp32 전체가 아니라 NBNS.begin()으로 NetBIOS Name Service를 활성화한 애플리케이션입니다. 공격자는 같은 네트워크에서 UDP/137에 접근할 수 있어야 합니다.

Source of truth

계층
기준
공식 상태·영향 버전
GitHub advisory와 CVEProject record
취약 코드
보존 checkout c1f40e35...libraries/NetBIOS/src/NetBIOS.cpp
수정 계보
PR #12486 · head bb860486... · merge 230f1f2e... · release 3.3.8
발견 과정
public repository 밖에 보존된 private raw Codex JSONL session
공개 실행 증거
CVE-public/cases/CVE-2026-41429/evidence/의 harness와 ASan log
설명 문서
이 Notion 페이지와 GitHub README; 둘 다 raw session 자체는 아님

원인 분석

취약 snapshot의 NetBIOS.cpp는 packet의 length field를 두 메모리 경계에 사용했습니다.
question = reinterpret_packet_as_nbns_question(packet); name_len = question->name_len; // attacker-controlled wire byte _getnbname(question->name, decoded, name_len); // source OOB 가능 if (configured_name == decoded) { struct NBNSAnswer answer; // answer.name[33] memcpy(answer.name, question->name, name_len + 1); // stack OOB write }
packet 전체가 존재하는지와 내부 field가 destination에 들어가는지는 서로 다른 조건입니다. 누락된 핵심 invariant는 1 <= question->name_len <= NBNS_MAX_HOSTNAME_LEN(32)였습니다.
입력에서 영향까지의 경로
  1. 공격자가 oversized name_len을 가진 UDP/137 NBNS packet을 보냅니다.
  1. _onPacket()이 packet을 packed struct로 해석하고 wire field를 그대로 사용합니다.
  1. read-side 입력에서는 짧은 source와 긴 길이 때문에 _getnbname()이 source 밖을 읽습니다.
  1. write-side 입력에서는 설정 이름의 정상 NetBIOS 인코딩과 terminator를 사용해 decode 결과를 ESP로 만들면서 name_len = 200을 유지합니다.
  1. 이름 비교를 통과한 뒤 memcpy()가 201 bytes를 33-byte nbnsa.name으로 복사합니다.

재현 및 검증

보존된 harness는 Arduino runtime이나 ESP32 장치를 emulation하지 않습니다. 취약 checkout에서 추출한 decode·copy logic을 host AddressSanitizer로 검증한 최소 재현입니다.
두 실행은 모두 ASan abort와 non-zero exit가 예상되므로 &&로 연결하지 않고 별도로 실행합니다.
cc -fsanitize=address -O0 -g netbios_read_repro.c -o read-repro cc -fsanitize=address -O0 -g netbios_write_repro.c -o write-repro set +e ./read-repro ./write-repro
두 harness는 동일한 byte sequence를 사용하지 않습니다. 공통점은 oversized name_len = 200입니다. read harness는 짧은 stack object를 사용하고, write harness는 짧은 ESP 이름으로 비교 branch를 통과하도록 충분한 heap-backed source를 사용합니다.
검증 항목
상태
보존된 근거와 한계
취약 data flow
확인 · 정적
vulnerable commit의 _getnbname()memcpy()
Source-side OOB read
확인 · host 동적
stack q object 끝에서 ASan READ size 1
응답 stack OOB write
확인 · host 동적
heap-backed source와 정상 ESP decode branch 후 ASan WRITE size 201
정상 길이 대조군
미보존
현재 archive에는 oversized 입력 두 건만 있음
UDP/137 도달 조건
확인 · 정적
NBNS.begin() listener 코드와 공식 example/advisory
Live ESP32 network packet
미검증
실제 장치 packet PoC와 crash log 없음
Fix source
확인 · 정적
길이 guard가 두 취약 operation보다 먼저 실행됨
3.3.8 runtime regression
미검증
수정 버전 실행 로그 없음
RCE
미검증·비주장
실기기 control-flow exploit 없음
보존된 ASan 로그의 핵심
READ REPRO ERROR: AddressSanitizer: stack-buffer-overflow READ of size 1 'q': [48, 98), access at offset 98 WRITE REPRO ERROR: AddressSanitizer: stack-buffer-overflow WRITE of size 201 in memcpy 'a': [48, 110), access at offset 110
committed log 자체에는 exact compiler version과 container image digest가 기록돼 있지 않으며, 해당 environment metadata는 이 public case evidence에 포함하지 않았습니다. combined log를 이 사례의 canonical runtime record로 사용합니다.

판단 변화: 가설에서 공개 CVE까지

이 절은 최종 결론만 나열하지 않고, 조사 과정에서 어떤 가정을 세웠고 어떤 질문·실험·공식 기록 때문에 판단을 바꿨는지를 시간순으로 기록합니다. 보존된 private session의 대화 원문, 이 CVE 범위 밖 후보의 세부 내용, 연락처는 공개하지 않으며 이 CVE의 판단을 바꾼 비민감 기술 사실만 요약합니다.
단계
당시 판단·가정
전환 계기
바뀐 판단
증거 수준·남은 한계
1. 공격면 탐색
특정 컴포넌트를 미리 정하지 않고 저장소 전체에서 외부 입력이 닿는 코드를 조사했습니다.
네트워크 parser, 파일·USB 처리, 문자열·buffer 조작을 우선순위로 두고 여러 후보를 비교했습니다.
공격자가 제어하는 길이가 두 메모리 경계에 사용되는 NetBIOS를 최우선 동적 검증 후보로 정했습니다.
후보 선정 단계이며 취약점 확정 전이었습니다.
2. 정적 가설과 길이 경계
question->name_len이 의심스러웠지만 최소 packet 길이 검사가 있어 안전할 가능성도 남아 있었습니다.
Packet 검사는 50-byte 구조체의 존재만 보장하고, 별도의 1-byte wire field가 1..32 범위인지는 확인하지 않은 채 _getnbname()과 응답 memcpy()에 전달되는 것을 확인했습니다.
Packet buffer boundary와 protocol field·destination boundary는 별도 검증 대상이며, 하나의 semantic length 누락이 source-side read와 destination-side write를 각각 깨뜨릴 수 있다는 가설을 세웠습니다.
정적 data flow와 구조체 크기만 확인했습니다.
3. 첫 동적 증거
두 위험한 operation이 보이므로 둘 다 실행될 수 있다고 예상했습니다.
첫 host-ASan 실행이 _getnbname()에서 READ of size 1 stack-buffer-overflow로 종료됐습니다.
Source-side OOB read는 동적으로 확정했지만, process가 먼저 abort했으므로 뒤의 write는 아직 입증되지 않았다고 증거 수준을 낮춰 분리했습니다.
Read 확인, write 미확인 상태였습니다.
4. 재검증 기준 강화
초기에는 강한 정적 후보와 첫 crash를 중심으로 보고 가능성을 판단했습니다.
사용자가 실제 도달 조건, 길이 제약, 오탐 가능성과 각 경로를 다시 확실히 검증해 달라고 요청했습니다.
“취약해 보이는 코드”가 아니라 claim마다 다른 오류의 간섭 없이 독립 증거를 만들기로 했습니다.
사용자 질문이 검증 설계를 바꾼 전환점입니다.
5. 첫 write 전용 실험
이름 비교를 통과하고 큰 길이로 복사하면 write overflow를 확인할 수 있다고 예상했습니다.
Source와 destination을 모두 stack에 둔 첫 write harness가 의도한 overflow보다 먼저 memcpy-param-overlap을 보고했습니다.
이 결과를 제품 취약점의 깨끗한 증거가 아닌 test-layout artifact로 판정하고 폐기했습니다.
False start를 숨기지 않고 증거에서 제외했습니다.
6. 실험 재설계
입력 내용뿐 아니라 harness의 메모리 배치도 결과를 오염시킬 수 있음을 확인했습니다.
Source를 256-byte heap allocation으로 옮기고 실제 코드와 같은 stack destination을 유지했습니다.
Overlap 없이 WRITE of size 201 stack-buffer-overflow를 독립 확인했습니다.
공개 write harness는 이 corrected version과 byte-identical합니다.
7. Branch feasibility
위험한 memcpy()가 있다는 사실만으로 제품의 비교 조건을 만족시킬 수 있다는 점까지 증명되지는 않았습니다.
ESP로 decode되는 implementation-accepted prefix와 AA zero pair를 구성하면서 wire field name_len = 200은 유지하고, C harness의 strcmp()로 비교 조건을 모델링했습니다.
짧게 decode된 이름과 oversized wire length가 함께 존재할 수 있어 뒤 201-byte copy path가 feasible하다고 판단했습니다.
실제 Arduino String::equals, _onPacket()과 UDP runtime은 실행하지 않았습니다.
8. 두 primitive 확정
하나의 입력이나 한 번의 실행이 모든 현상을 보여줘야 한다고 오해할 수 있었습니다.
Read는 짧은 source로, write는 충분한 source와 정상 이름 branch로 서로 다른 process에서 실행했습니다.
OOB read와 stack OOB write를 서로 다른 입력 형태로 독립 검증한 두 primitive로 정리했습니다.
한 packet 실행에서 두 오류가 연속 발생했다는 증거는 아닙니다.
9. 사용자 재현 보고
Codex가 만든 harness와 실행 결과가 있었습니다.
사용자가 session에서 안내된 read/write harness의 재현 성공을 각각 보고했습니다.
Host 환경에서의 반복 가능성에 대한 신뢰가 높아졌습니다.
별도의 사용자 실행 로그·환경은 보존되지 않았으며, live ESP32·UDP packet 재현으로 확장하지 않았습니다.
10. 노출 범위 교정
취약 코드가 저장소에 있다는 사실을 제품 전체 노출로 넓게 해석할 여지가 있었습니다.
사용자가 서비스 활성 조건과 대상 장치를 질문했고, listener 생성 경로와 공식 example을 다시 확인했습니다.
libraries/NetBIOS를 사용하고 NBNS.begin(...)을 호출한 ESP32 애플리케이션만 runtime에 노출되며 공격자는 같은 네트워크에서 UDP/137에 도달해야 한다고 좁혔습니다.
Reachability는 정적 확인이며 live packet은 보내지 않았습니다.
11. 영향 주장 조정
초기에는 host memory error를 장치 crash와 더 높은 영향으로 곧바로 연결할 위험이 있었습니다.
사용자가 RCE 가능성과 보고 시점을 질문했고, host harness와 실제 ESP32 runtime의 증거 차이를 재검토했습니다.
직접 확인한 것은 host OOB read/write로 한정했습니다. 장치 DoS는 공식 advisory의 credible impact, RCE는 미입증·비주장으로 분리했습니다.
실장치 crash/reset과 control-flow hijack은 미검증입니다.
12. 보고 시점 결정
RCE까지 입증한 뒤 보고할지 검토할 수 있었습니다.
비인증 adjacent-network 입력, 두 memory-unsafe primitive, 수정 필요성과 지연 비용을 비교했습니다.
RCE 연구 때문에 비공개 보고를 미루지 않고 확인된 memory corruption과 한계를 함께 보고하기로 했습니다.
가능성과 확인 사실을 구분한 채 진행했습니다.
13. Disclosure package 정리
기술 결과가 맞으면 report 본문과 첨부 표현도 충분하다고 보기 쉬웠습니다.
비공개 제보 경로, triage용 설명과 artifact 목록을 검토하는 과정에서 “four files”라고 쓰고 세 항목만 열거한 count 불일치를 발견했습니다.
존재하지 않는 네 번째 자료를 추정하지 않고, 실제 보존된 두 harness와 combined log라는 세 기술 artifact를 정확히 구분했습니다.
연락 원문·개인 정보는 제외하고 비민감 결정과 공개 artifact만 남겼습니다.
14. 수정 검증
PR이 merge됐다는 사실만으로 두 취약 경로가 막혔다고 간주할 수 있었습니다.
사용자가 PR 상태가 아니라 실제 guard의 효과를 직접 확인해 달라고 요청했고, 로컬 checkout이 오래된 상태임을 발견해 upstream diff를 다시 가져왔습니다.
두 operation 앞의 1..32 선행 guard가 공통 원인을 제거한다고 정적으로 확인했습니다.
Fix source는 확인했지만 3.3.8 runtime regression은 수행하지 않았습니다.
15. 공개 lifecycle 구분
Patch merge, fixed release, advisory 공개와 CVE assignment를 하나의 완료 상태로 볼 수 있었습니다.
Vendor와의 조정 과정 및 공개 metadata를 단계별로 확인했습니다.
Merge는 코드 반영, tag는 배포 가능한 release, GHSA와 CVE는 별도의 조정·공개 절차로 구분했습니다.
각 사실의 source of truth를 따로 유지했습니다.
16. Release 판단 교정
Session 당시 로컬 tag ref만 보고 “patched tagged release 없음”으로 판단했습니다.
이후 공식 release metadata, GHSA와 CVE record를 상호 검증했습니다.
해당 판단은 stale local refs로 인한 오류였고, 3.3.8이 2026-04-12 이미 공개됐음을 확인했습니다. 최종 범위는 affected < 3.3.8, fixed 3.3.8로 교정했습니다.
이 교정은 session 종료 후의 후속 감사에서 이루어졌습니다. Session은 발견 과정, 공식 기록은 최종 버전·날짜의 기준으로 분리했습니다.

1. 전체 저장소 탐색에서 NetBIOS 우선순위 결정까지

조사는 NetBIOS를 미리 정해 놓고 시작하지 않았습니다. 먼저 외부 입력이 직접 들어오는 parser와 고정 크기 C buffer가 만나는 지점을 넓게 조사하고, 여러 후보의 입력 제어 가능성·도달성·영향을 비교했습니다. 다른 후보의 세부 내용은 이 공개 CVE 기록의 범위가 아니므로 제외합니다.
NetBIOS를 우선한 이유는 단순한 길이 검사 누락 하나가 보였기 때문이 아닙니다. 공격자가 packet에서 지정하는 question->name_len이 decode source traversal과 고정 stack response copy라는 서로 다른 메모리 경계에 모두 사용됐기 때문입니다. 이 단계의 결론은 “CVE 확정”이 아니라 “두 종류의 memory primitive가 존재할 수 있으므로 가장 먼저 실행 증거를 만들어야 하는 후보”였습니다.

2. 첫 ASan 결과가 증명한 것과 증명하지 못한 것

취약 logic을 추출한 초기 host-ASan 실행에서는 _getnbname()이 짧은 source object의 끝을 읽는 순간 READ of size 1 stack-buffer-overflow가 발생했습니다. 정적 가설 중 read-side 문제는 실행 증거로 바뀌었습니다.
이때 최소 packet 길이 검사가 있다는 사실도 다시 분리해 해석했습니다. 해당 검사는 packed question 구조체가 packet 안에 존재한다는 것만 보장합니다. 그 안의 name_len이 33-byte 이름 배열과 응답 destination에 맞는지는 보장하지 않습니다. Packet 전체 크기와 내부 semantic length를 같은 경계로 취급하지 않게 된 것이 이후 fix 조건 1..32를 정확히 평가하는 기준이 됐습니다.
그러나 ASan은 첫 오류에서 process를 abort했습니다. 같은 코드 흐름 뒤에 위험한 memcpy()가 존재하더라도 그 실행이 write까지 도달했다는 의미는 아니었습니다. 따라서 판단을 “두 취약점이 확인됐다”로 올리지 않고 다음처럼 나눴습니다.
  • Read primitive: host 동적 확인
  • Write primitive: 정적 의심, 별도 실행 필요
사용자의 재검증 요청 이후 목표도 “sanitizer crash 하나를 얻는 것”에서 “각 claim이 실제로 도달 가능하고 다른 오류에 가려지지 않는지 검증하는 것”으로 바뀌었습니다.

3. 실패한 write harness를 증거에서 제외한 이유

Write 경로를 분리하기 위해 설정 이름과 일치하는 encoded name을 만들고 큰 name_len을 유지하는 전용 harness를 작성했습니다. 하지만 첫 version은 packet source backing buffer와 destination object를 모두 stack에 배치했습니다. 그 결과 201-byte memcpy()의 source와 destination 범위가 test process의 stack layout에서 겹쳐 memcpy-param-overlap이 먼저 발생했습니다.
이 오류는 실제 제품 코드의 destination 경계가 안전하다는 반증도 아니고, 의도한 stack overwrite를 깨끗하게 입증한 결과도 아니었습니다. Harness가 만든 별도 undefined behavior가 관찰을 오염시켰으므로 이 실행은 제품 취약점 증거에서 제외했습니다.
Corrected harness에서는 source를 256-byte heap allocation으로 옮기고 응답 object는 stack에 유지했습니다. Source와 destination을 분리한 뒤에는 overlap 없이 nbnsa.name을 포함한 stack object의 끝을 넘는 WRITE of size 201이 관찰됐습니다. 실패 원인을 숨기지 않고 폐기 사유와 통제 조건을 남긴 것이 첫 번째 큰 판단 교정이었습니다.

4. 위험한 copy에서 실제 branch 도달성까지

Oversized 길이가 memcpy()에 사용된다는 사실만으로는 충분하지 않았습니다. 실제 제품 흐름은 먼저 decoded name이 설정된 이름과 일치해야 하므로 해당 비교 조건을 공격자가 만족시킬 수 있는지 확인해야 했습니다.
이를 확인하기 위해 source 앞부분에는 ESP로 decode되는 implementation-accepted encoded prefix를 넣고 그 뒤에 AA zero pair를 배치했습니다. C harness는 제품의 Arduino String::equals 대신 strcmp(name, "ESP")로 같은 비교 조건을 모델링했습니다. Decode 결과는 ESP가 되지만 packet struct에서 읽은 원래 wire field name_len200으로 남았습니다. 이후 copy는 decode된 문자열의 실제 길이가 아니라 name_len + 1, 즉 201 bytes를 사용했습니다.
따라서 판단은 “큰 값이 copy 길이로 쓰이면 위험하다”에서 “짧게 decode된 일치 이름과 oversized wire length가 함께 존재할 수 있어 copy path가 feasible하다”로 강화됐습니다. 이는 extracted C logic의 path-feasibility 확인이며 실제 _name.equals(name), _onPacket() 또는 live UDP path를 실행한 결과는 아닙니다.

5. 하나의 crash가 아니라 두 개의 독립 primitive

최종 재현은 하나의 universal packet이나 한 번의 sanitizer 실행으로 모든 현상을 설명하지 않습니다.
  • Read harness는 짧은 source object를 사용해 _getnbname()의 source-side OOB read를 확인합니다.
  • Write harness는 충분한 source를 제공해 read failure가 먼저 발생하지 않게 하고, 제품의 이름 비교 조건을 strcmp()로 모델링한 뒤 fixed stack destination을 넘는 copy를 확인합니다.
두 harness가 공유하는 공격 조건은 oversized name_len = 200이지만, byte sequence와 source layout은 다릅니다. 각 claim을 다른 오류의 간섭 없이 검증하기 위해 의도적으로 분리한 것입니다. 따라서 “한 packet에서 read와 write가 순서대로 모두 관찰됐다”가 아니라 “제품 data flow의 두 위험 경로를 각각 독립 검증했다”가 정확한 결론입니다.
Codex의 재검증 뒤 사용자는 session에서 두 harness의 재현 성공을 각각 보고했습니다. 이로써 host 환경에서의 반복 가능성에 대한 신뢰가 높아졌습니다. 다만 별도의 사용자 실행 로그와 환경 metadata는 보존되지 않았고, 이 보고를 Arduino runtime, live UDP packet, 실제 ESP32 crash나 RCE 증거로 확장하지 않았습니다.

6. 노출 범위와 영향 주장을 좁힌 과정

취약 코드가 arduino-esp32 저장소에 포함돼 있다는 사실만으로 모든 장치가 runtime에 노출되는 것은 아닙니다. 후속 질문을 통해 다음 전제조건을 다시 확인했습니다.
  • 대상 펌웨어가 libraries/NetBIOS를 사용해야 합니다.
  • Runtime에 NBNS.begin(...)을 호출해 UDP/137 listener를 열어야 합니다.
  • 공격자가 일반적으로 같은 LAN·AP·사내망에서 UDP/137에 도달할 수 있어야 합니다.
따라서 제품 전체나 인터넷 전역 노출을 주장하지 않고 adjacent-network 조건으로 좁혔습니다.
영향도 같은 방식으로 낮춰 썼습니다. Stack OOB write는 높은 영향도의 가능성을 만들지만, 실제 exploitability는 ESP32 아키텍처, 컴파일 옵션, stack frame 배치와 보호 기법에 따라 달라집니다. 이 조사에서 직접 확인한 것은 host ASan의 OOB read와 stack OOB write입니다. 실제 ESP32 장치의 crash/reset은 직접 재현하지 않았으며 DoS는 공식 advisory가 평가한 credible impact입니다. Control-flow hijack을 만들지 않았으므로 RCE는 가능성만 남기고 결과로 주장하지 않았습니다.
사용자가 RCE까지 기다릴지 질문했을 때는, 비인증 네트워크 입력에 도달 가능한 memory corruption과 재현 가능한 두 primitive가 이미 private report를 시작하기에 충분하다고 판단했습니다. RCE 연구 때문에 보고를 지연하지 않고 확인 사실·공식 평가·미검증 영역을 분리해 전달했습니다.

7. 발견 증거에서 검토 가능한 disclosure package까지

동적 검증이 끝난 뒤에는 결과를 그대로 붙여 넣는 것보다 vendor가 다시 확인할 수 있는 형태로 정리하는 과정이 필요했습니다. Private report 본문은 공격 조건, 두 primitive, 재현 방법과 비주장 범위를 중심으로 압축하고, 실행 증거는 별도 artifact로 구분했습니다.
이 과정에서 “four files”라고 표현하면서 실제 목록에는 세 항목만 적힌 count 불일치를 발견했습니다. 누락된 파일이 있다고 추정하거나 숫자에 맞춰 자료를 만들어 내지 않고, 실제 보존된 원 연구 기술 artifact가 다음 세 개라는 사실을 기준으로 교정했습니다.
  • Read harness
  • Corrected write harness
  • 두 실행을 보존한 combined ASan log
공식 advisory에는 당시의 역사적 count 오류가 남아 있으므로, 현재 문서는 그 문구를 그대로 반복하지 않고 공개 repository에서 실제 확인 가능한 세 artifact와 hash를 기준으로 설명합니다. 또한 공개 페이지에는 private correspondence 원문이나 개인 연락 정보를 옮기지 않고, 제보·검토·승인이라는 비민감 사건만 요약했습니다.

8. PR merge와 실제 수정 검증을 분리한 과정

Vendor가 patch 검토를 요청했을 때 PR은 이미 merge된 상태였습니다. 이때 필요한 답은 “PR이 열려 있는가”가 아니라 “추가된 invariant가 보고한 두 경로를 실제로 차단하는가”였습니다. 사용자가 직접 확인을 요청한 뒤, 로컬 checkout이 취약 snapshot에 머물러 있다는 점을 발견하고 최신 upstream diff를 기준으로 다시 검토했습니다.
Merged patch는 _getnbname()memcpy()보다 앞에서 다음 조건을 거부합니다.
if (question->name_len == 0 || question->name_len > NBNS_MAX_HOSTNAME_LEN) { return; }
이 guard는 공격자 제어 길이를 1..32로 제한하므로 read와 write에 각각 다른 검사를 추가한 것이 아니라 두 현상의 공통 원인을 선행 조건 하나로 제거합니다. Source review 기준으로 두 경로를 차단한다고 판단했습니다. 하지만 fixed 3.3.8에서 동일 입력을 실행한 regression log는 없으므로 “수정 코드를 정적으로 확인”한 것과 “수정 버전을 동적으로 검증”한 것을 구분했습니다.

9. Patch, release, advisory와 CVE를 분리한 과정

조정 과정에서 다음 상태를 서로 다른 단계로 정리했습니다.
  1. PR merge: 개발 branch에 patch가 반영된 상태
  1. Tagged release: 사용자가 설치할 수 있는 수정 버전이 공개된 상태
  1. Security advisory: Vendor가 영향과 완화책을 공식 공개한 상태
  1. CVE assignment·publication: 식별자와 CVE record가 조정·공개된 상태
Session 당시 로컬 tag ref에서는 fix를 포함한 release가 보이지 않아 “patched tagged release 없음”으로 잘못 판단했습니다. Merge와 release를 구분한 원칙 자체는 맞았지만, stale local refs를 최신 공식 상태로 오해한 사실 판단은 틀렸습니다.
후속 감사에서 공식 release metadata, GHSA와 CVE record를 상호 검증한 결과 3.3.8은 2026-04-12 이미 공개된 상태였습니다. 따라서 최종 문서에서는 발견 당시 session을 연구 과정의 source로 사용하고, 영향 버전·수정 버전·공개일은 이후의 공식 기록을 source of truth로 사용합니다. 오류를 지우는 대신 왜 틀렸고 어떤 근거로 교정했는지를 남겼습니다.

최종적으로 확립된 조사 원칙

이 사례에서 가장 크게 바뀐 것은 취약점 자체보다 증거를 다루는 기준이었습니다.
  • 정적 data flow, sanitizer 실행, 실제 장치 동작과 공식 advisory는 서로 다른 증거 계층입니다.
  • 첫 crash가 뒤의 독립 경로를 가릴 수 있으므로 primitive별로 process와 input을 분리해야 합니다.
  • Harness의 memory layout이 만든 artifact는 제품 취약점 증거로 사용하지 않습니다.
  • 위험한 operation의 존재뿐 아니라 실제 조건 분기의 도달성을 확인합니다.
  • 가능성, 직접 확인, 공식 평가와 비주장을 문서에서 분리합니다.
  • Merge, fixed release, advisory와 CVE publication을 서로 다른 lifecycle 단계로 추적합니다.
  • 당시 판단과 이후 공식 기록이 충돌하면 오류를 숨기지 않고 판단을 바꾼 이유와 새로운 source of truth를 함께 남깁니다.

영향과 비주장

NBNS가 활성화된 경우 인증되지 않은 adjacent-network 공격자가 UDP/137 parser에 도달할 수 있습니다. 보존된 host harness가 직접 확인한 결과는 OOB read와 stack OOB write입니다.
실제 ESP32 장치의 crash/reset은 직접 재현하지 않았으며 denial-of-service는 공식 advisory가 평가한 credible impact입니다. 코드 실행 가능성도 배제할 수 없지만 이 조사에서는 control-flow hijack이나 RCE를 입증하거나 주장하지 않습니다. 인터넷 전역 도달성 및 NBNS가 비활성화된 장치의 영향도 주장하지 않습니다.

수정 계보

  • PR head/fix: bb860486fc877793a0ec6e8c94c580d0df3a01a1
  • 공식 affected range: < 3.3.8
  • introducing commit: 확인하지 않았으므로 특정하지 않음
실제 merged patch는 _getnbname() 직전에 하나의 선행 guard를 추가합니다.
if (question->name_len == 0 || question->name_len > NBNS_MAX_HOSTNAME_LEN) { return; }
이는 read와 copy에 별도 검사를 추가한 것이 아니라 두 downstream 사용 전에 name_len1..32로 제한합니다. 추가 packet-length hardening 권고와 실제 적용 patch는 구분합니다. patch diff는 정적으로 확인했지만 3.3.8에서의 동일-input runtime regression은 보존되지 않았습니다.

타임라인

조사·메일 날짜는 Asia/Seoul, Z가 붙은 공개 metadata timestamp는 UTC입니다.
시점
사건
근거
2026-03-27 KST
취약 data flow 발견, split host-ASan read/write 확인, private report 초안·artifact 준비
retained raw session
2026-03-27T10:21:26Z
PR #12486 공개
upstream PR
2026-04-01T10:43:11Z
fix가 230f1f2e...로 merge
upstream commit
2026-04-12T07:03:08Z
arduino-esp32 3.3.8 공개
upstream release
2026-04-14 KST
vendor의 PR 검증 요청과 연구자의 static review
retained private correspondence/session
2026-04-15 KST
vendor가 CVE와 advisory 진행을 요청
retained private correspondence/session
2026-04-16T08:56:49Z
GHSA 공개
GitHub advisory metadata
2026-04-20T15:32:33.814Z
CVE 예약
CVE record
2026-04-24T19:19:49.594Z
CVE record 공개
CVE record
2026-04-27T13:34:49.485Z
CVE record 갱신
CVE record
2026-08-22/23 KST
공개 사례 import와 사례별 SHA256SUMS audit
public Git history
2026-09-02 KST
ASan 분류·명령·claim boundary·provenance 재감사
current audit

관련 파일

재현과 관찰

공식 기록

 

감사와 무결성

  • PROVENANCE.md — vulnerable/fix/session과 private-to-public lineage

느낀 점

첫 sanitizer failure 하나만으로 전체 data flow를 판단하면 뒤의 독립적인 primitive를 놓칠 수 있습니다. source layout을 분리해 read와 write를 각각 확인하되, harness가 실제 제품 runtime 전체를 대체하지 않는다는 경계를 함께 보존해야 합니다.
또한 private discovery session, public reproduction artifacts, later official records는 서로 다른 시점과 역할을 가집니다. 중간 판단이 최종 metadata와 충돌할 때는 그 변화 자체를 기록하고 각 사실의 source of truth를 분리해야 합니다.