보안동아리에서 한 학기짜리 프로젝트로 IoT/임베디드 기기 해킹을 잡았다. 팀은 4명, 리버싱 한 명, 웹해킹 한 명, 프론트 한 명, 그리고 나. 타깃 기기를 뭘로 할지 고민하다가 집에 안 쓰고 굴러다니던 ipTIME 공유기 N2V가 떠올라서 그걸로 정했다.
기기를 정하고 나면 제일 먼저 하는 게 모델명으로 알려진 CVE부터 검색해보는 거다. 그렇게 검색하자마자 CVE-2025-55423이 떴는데, 영향받는 모델 목록 163종 안에 N2V가 정확히 들어 있었다. 분석 대상은 이 글 전체에서 내 소유의 N2V 공유기이고, 격리된 테스트 LAN에서만 다뤘다는 걸 먼저 밝혀둔다.
CVE-2025-55423은 OS Command Injection(CWE-78)이다. 인증 없이 네트워크에 인접한 공격자가 root 권한으로 임의 명령을 실행할 수 있다. 취약 함수는 옛 펌웨어 기준 /lib/libcgi.so의 upnp_relay()이고, 새 펌웨어에서는 /lib/libesysapi.so의 upnp_relay_add() / upnp_relay_rm()이다. 163개 모델이 영향을 받는데, N2V가 속한 EoS(단종) 라인은 패치가 안 나온다. 벤더 권장도 "기기 폐기 또는 포트포워딩 릴레이 기능 비활성화"다.
이 기능 자체를 먼저 이해해야 했다. ipTIME 펌웨어 9.90.4 이후 추가된 UPnP Relay는 이런 상황을 자동화한다. ISP 공유기 아래에 ipTIME 공유기가 2단으로 물려 있고, 그 아래 내 PC가 있다고 하자. 내 PC의 앱이 UPnP로 ipTIME 공유기에 포트를 열어달라고 요청하면, ipTIME이 그 요청을 받아서 자기 NAT에 룰을 추가한다. 근데 외부에서 보면 그 포트는 여전히 ISP 공유기에 막혀 있다. 그래서 ipTIME이 상위 공유기(ISP 라우터)에도 같은 룰을 자동으로 등록해주는 게 UPnP Relay다.
이때 상위 공유기에 SOAP 요청을 보내야 하는데, 그 요청의 엔드포인트가 controlURL이다. 즉 LAN에 있는 누군가가 UPnP로 포트포워딩을 요청하면, ipTIME은 그 요청에서 뽑아낸 controlURL을 가지고 상위 공유기에 또 SOAP 요청을 날린다. 문제는 이 값이 검증 없이 처리된다는 데 있었다.
공개된 분석에 따르면 옛 펌웨어의 upnp_relay()는 대략 이런 식이었다(의사코드).
void upnp_relay(...) {
char *control_url = parse_soap_action(request);
char cmd[1024];
snprintf(cmd, sizeof(cmd),
"curl -s -X POST %s -d '...'",
control_url); // 검증 없음
system2(cmd); // 그대로 셸에 던짐
}
controlURL은 LAN에 있는 디바이스가 보낸 UPnP/SSDP 응답에서 가져오는 값인데, 어떤 검증이나 이스케이핑도 없이 system() 계열 함수에 그대로 들어간다. 세미콜론이나 파이프 같은 셸 메타문자가 그대로 통과한다는 뜻이다.
이론상 공격자가 SSDP 응답을 위조해서 controlURL에 http://192.168.0.1/ctl/IPConn;reboot 같은 값을 실어 보내면, ipTIME은 그 값을 그대로 명령어 문자열에 끼워넣어 실행하게 된다. 세미콜론 뒤의 reboot이 root 권한으로 실행되는 구조다. 공개된 분석 자료에는 SSDP 가짜 응답, ARP/DHCP 스푸핑을 조합해 라우터가 공격자를 상위 공유기로 착각하게 만드는 전체 공격 체인이 단계별로 나와 있는데, 이 글에서는 취약점의 근본 원인과 방어 원리 위주로만 정리하고 구체적인 공격 절차는 옮기지 않기로 했다. 어차피 지금 N2V는 패치가 안 나오는 EoS 상태라 상세 절차를 공개하는 게 도움보다 위험이 더 크다고 판단했다.
벤더는 EoS가 아닌 23개 제품군에는 패치를 배포했다. 패치된 펌웨어에서는 system() 대신 execvpe() / execvp()를 쓴다. 이게 핵심이다. system()은 인자를 통째로 셸(/bin/sh -c)에 넘기기 때문에 메타문자가 해석되지만, exec*() 계열은 인자를 배열로 받아서 셸을 거치지 않는다. controlURL 값이 들어가도 그냥 하나의 문자열 인자로만 취급될 뿐, 셸이 그걸 다시 해석하지 않는다.
// 패치 후 (대략)
char *argv[] = {"curl", "-s", "-X", "POST", control_url, "-d", "...", NULL};
execvpe("curl", argv, envp);
이게 커맨드 인젝션을 막는 정석이다. 우리집 N2V는 EoS라 이 패치를 못 받고, 벤더가 대놓고 "버리거나 기능을 꺼라"라고 하는 이유가 여기 있다.
system(), popen(), eval, os.system() — 이름은 다 다르지만 사용자 입력을 셸 명령 문자열의 일부로 끼워넣는 패턴은 거의 다 위험하다. 막는 방법은 두 가지뿐이다. 셸을 아예 거치지 않는 API(execve(), posix_spawn(), Python의 subprocess.run([...]) 리스트 형태)를 쓰거나, 입력을 화이트리스트로 검증하고 이스케이핑하는 것. 후자는 까먹기 쉬워서 항상 전자가 권장된다. 이 CVE는 둘 다 안 한 케이스였다.
그리고 UPnP 자체가 LAN 내부 기기들이 서로 신뢰한다는 전제로 설계돼 있다는 점도 짚을 만하다. 인증 없이 SSDP 응답으로 임의 controlURL을 실어보낼 수 있고, 라우터는 그걸 의심 없이 처리한다. LAN이 안전한 공간이라는 가정이 깨지는 순간(악성 IoT 기기, 게스트 와이파이, 손님 노트북 하나만 있어도) UPnP는 곧바로 위험 자산이 된다.
이번 CVE를 미리 알고 시작한 건 사실 운이 좋았던 쪽이다. 만약 알려진 취약점이 없었다면 binwalk으로 펌웨어를 까서 직접 함수부터 찾았어야 했을 거고, 실제로 다음 단계로 그 과정을 그대로 재현해보고 있다. 패치 전후 펌웨어를 각각 받아서 libcgi.so와 libesysapi.so를 Ghidra로 열어보고, system2() 호출이 execvpe()로 어떻게 바뀌었는지 디스어셈블 단위로 비교해보는 게 다음 회차 목표다.
exec* vs system)취약점 분석 전에 같은 기기의 펌웨어를 binwalk로 추출한 과정은 binwalk으로 ipTIME N2V 펌웨어 까보기에 정리했다.
취약점 번호와 심각도는 블로그 표현이 아니라 NVD·CVE 레코드의 현재 상태를 기준으로 다시 확인해야 한다. 제품별 영향 버전과 패치 여부도 제조사 펌웨어 페이지에서 보는 편이 안전하다. 아래 자료는 공격 재현 절차가 아니라 공개 기록과 입력 검증 원칙을 확인하기 위한 참고 화면이다.
방어의 핵심은 셸 명령 문자열에 사용자 입력을 이어붙이지 않는 것이다. 외부 프로세스가 꼭 필요하면 인자를 배열로 전달하고, 허용 목록과 타입·길이 검증을 적용한다. 공유기는 관리 인터페이스를 외부에 노출하지 않고 최신 펌웨어를 유지하는 기본 조치도 함께 필요하다.
| AIxCC 우승 시스템 ATLANTIS·Crete 로컬 설치 후기 (1) | 2026.07.26 |
|---|---|
| CTF - Cascading the Seven Seas: 브라우저 DevTools로 숨겨진 플래그 찾기 (0) | 2026.07.25 |
| 드림핵 시스템 해킹 입문 — 무료 자료만으로 시작하는 루트 (0) | 2026.07.24 |
| AFL++로 네트워크 포렌식 파서 퍼징해서 GHSA 10건 제보·게재까지 (0) | 2026.07.23 |
| binwalk으로 ipTIME N2V 펌웨어 까보기 (squashfs 추출까지) (0) | 2026.07.23 |