UPnP 동작 방식과 보안 문제: SSDP 기기 발견, SOAP 제어, IGD 포트 포워딩
이 글의 핵심
게임이나 P2P 앱을 실행하면 공유기 설정을 건드리지 않았는데도 포트가 열리는 이유가 UPnP IGD입니다. 편리한 대신 인증 없이 내부 네트워크의 어떤 기기든 포트 매핑을 요청할 수 있다는 점이 보안 문제의 출발점입니다. 프로토콜 스택과 구현 예제를 따라가며 언제 UPnP를 꺼야 하고 어떤 대안을 써야 하는지 판단할 수 있게 정리했습니다.
들어가며: UPnP가 필요한 이유
홈 네트워크에서 게임, P2P 애플리케이션, IoT 기기를 사용할 때 NAT(Network Address Translation) 때문에 외부에서 접근이 차단됩니다. UPnP(Universal Plug and Play)는 이러한 문제를 자동으로 해결해주는 프로토콜입니다.
UPnP의 핵심 기능:
- 기기 자동 발견 (Device Discovery)
- 서비스 설명 및 제어
- 자동 포트 포워딩 (NAT Traversal)
- 이벤트 구독 및 알림
UPnP 기본 개념
UPnP란?
UPnP(Universal Plug and Play)는 네트워크 기기들이 설정 없이 자동으로 서로를 발견하고 통신할 수 있게 해주는 프로토콜 집합입니다.
UPnP 동작 과정
sequenceDiagram
participant App as Application
participant Router as UPnP Router
participant Internet as Internet
Note over App,Router: 1. Discovery (SSDP)
App->>Router: M-SEARCH (멀티캐스트)
Router->>App: HTTP 200 OK (유니캐스트, LOCATION 포함)
Note over App,Router: 2. Description
App->>Router: GET /description.xml
Router->>App: Device & Service 정보
Note over App,Router: 3. Control (SOAP)
App->>Router: AddPortMapping (포트 8080)
Router->>App: Success
Note over App,Internet: 4. 외부 접근 가능
Internet->>Router: Request to Public_IP:8080
Router->>App: Forward to 192.168.1.100:8080
Note over App,Router: 5. Cleanup
App->>Router: DeletePortMapping
Router->>App: Success
주요 사용 사례
- 게임 콘솔: Xbox, PlayStation의 NAT 타입 개선
- P2P 애플리케이션: BitTorrent, Skype
- IoT 기기: 스마트 홈 기기 자동 연결
- 미디어 스트리밍: DLNA, Plex
- 원격 데스크톱: VNC, RDP
UPnP 프로토콜 스택
UPnP 레이어 구조
flowchart TB
subgraph Layer6[Application Layer]
App[UPnP Application]
end
subgraph Layer5[UPnP Protocol]
Discovery["Discovery\nSSDP"]
Description["Description\nXML"]
Control["Control\nSOAP"]
Event["Eventing\nGENA"]
end
subgraph Layer4[Transport]
HTTP[HTTP]
UDP[UDP Multicast]
end
subgraph Layer3[Network]
IP[IP]
end
App --> Discovery
App --> Description
App --> Control
App --> Event
Discovery --> UDP
Description --> HTTP
Control --> HTTP
Event --> HTTP
HTTP --> IP
UDP --> IP
프로토콜 구성 요소
| 프로토콜 | 용도 | 포트 | 전송 방식 |
|---|---|---|---|
| SSDP | 기기 발견 | 1900 | UDP 멀티캐스트 |
| HTTP | 설명 문서 | 가변 | TCP |
| SOAP | 서비스 제어 | 가변 | HTTP POST |
| GENA | 이벤트 알림 | 가변 | HTTP |
SSDP: 기기 발견
SSDP란?
SSDP(Simple Service Discovery Protocol)는 UDP 멀티캐스트를 사용하여 네트워크 기기를 발견합니다.
SSDP는 HTTP 문법을 빌려 쓰지만 TCP가 아니라 UDP 위의 HTTP(HTTPU/HTTPMU)입니다. 요청 줄의 *는 특정 리소스가 아니라 “네트워크 전체”를 뜻하고, MX: 3은 응답하는 기기들이 0~3초 사이에 무작위로 기다렸다가 응답하라는 뜻입니다. 기기 수십 대가 동시에 응답해 요청자의 수신 버퍼가 넘치는 것을 막기 위한 장치라서, 검색하는 쪽은 최소 MX초 이상 응답을 기다려야 합니다. 검색 요청에 대한 응답은 멀티캐스트 NOTIFY가 아니라 요청자에게 유니캐스트로 오는 HTTP/1.1 200 OK이고, NOTIFY는 기기가 켜지거나 꺼질 때(ssdp:alive/ssdp:byebye) 스스로 멀티캐스트하는 광고 메시지입니다. CACHE-CONTROL: max-age=1800은 이 광고가 30분간 유효하니 그 전에 다시 알리겠다는 의미입니다.
SSDP Discovery 메시지
M-SEARCH (기기 검색)
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 3
ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1
USER-AGENT: OS/version UPnP/1.1 product/version
NOTIFY (기기 알림)
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:1900
CACHE-CONTROL: max-age=1800
LOCATION: http://192.168.1.1:5000/description.xml
NT: urn:schemas-upnp-org:device:InternetGatewayDevice:1
NTS: ssdp:alive
SERVER: Linux/3.14 UPnP/1.1 Router/1.0
USN: uuid:12345678-1234-1234-1234-123456789abc::urn:schemas-upnp-org:device:InternetGatewayDevice:1
Python으로 SSDP 구현
import socket
import struct
SSDP_ADDR = "239.255.255.250"
SSDP_PORT = 1900
def discover_upnp_devices():
"""UPnP 기기 검색"""
msg = (
'M-SEARCH * HTTP/1.1\r\n'
'HOST: 239.255.255.250:1900\r\n'
'MAN: "ssdp:discover"\r\n'
'MX: 3\r\n'
'ST: ssdp:all\r\n'
'\r\n'
).encode('utf-8')
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(5)
# 멀티캐스트 그룹 가입
mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
# M-SEARCH 전송
sock.sendto(msg, (SSDP_ADDR, SSDP_PORT))
devices = []
try:
while True:
data, addr = sock.recvfrom(8192)
response = data.decode('utf-8')
# LOCATION 추출
for line in response.split('\r\n'):
if line.startswith('LOCATION:'):
location = line.split(':', 1)[1].strip()
devices.append({
'location': location,
'address': addr[0]
})
break
except socket.timeout:
pass
sock.close()
return devices
# 실행
devices = discover_upnp_devices()
for device in devices:
print(f"Found device at {device['location']}")
이 코드는 개념을 보여 주기에는 충분하지만, 실제 공유기에 돌려 보면 몇 가지 이유로 기기를 못 찾는 경우가 있습니다. 첫째, 응답 헤더 이름은 HTTP처럼 대소문자를 구분하지 않으므로 LOCATION:만 비교하면 Location:으로 보내는 기기를 놓칩니다. line.lower().startswith('location:')로 비교해야 합니다. 둘째, M-SEARCH 응답은 유니캐스트로 오기 때문에 응답을 받는 데 멀티캐스트 그룹 가입(IP_ADD_MEMBERSHIP)은 필요 없고, struct.pack("4sl", ...)의 l은 64비트 Linux에서 8바이트라 ip_mreq 구조체 크기와 맞지 않아 플랫폼에 따라 에러가 날 수 있습니다("4s4s"와 inet_aton("0.0.0.0") 조합이 이식성이 좋음). 셋째, PC에 VPN, Docker, 가상 머신용 네트워크 어댑터가 여러 개 있으면 멀티캐스트가 엉뚱한 인터페이스로 나가 응답이 오지 않습니다. 이때는 IP_MULTICAST_IF 소켓 옵션으로 공유기와 같은 대역의 인터페이스를 지정해야 합니다.
SOAP: 서비스 제어
Device Description
기기 발견 후, LOCATION URL에서 XML 설명 문서를 가져옵니다.
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>
<friendlyName>Home Router</friendlyName>
<manufacturer>ASUS</manufacturer>
<modelName>RT-AX88U</modelName>
<UDN>uuid:12345678-1234-1234-1234-123456789abc</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>
<controlURL>/ctl/IPConn</controlURL>
<eventSubURL>/evt/IPConn</eventSubURL>
<SCPDURL>/WANIPConnection.xml</SCPDURL>
</service>
</serviceList>
</device>
</root>
이 설명 문서에서 중요한 필드는 serviceType과 controlURL입니다. 공유기마다 서비스 종류가 달라서, 일반 IP 연결은 WANIPConnection:1(또는 IGD v2의 WANIPConnection:2), PPPoE로 인터넷에 붙는 공유기는 WANPPPConnection:1을 노출합니다. 아래 파이썬 예제처럼 WANIPConnection만 찾으면 PPPoE 환경에서는 “게이트웨이를 찾을 수 없음”이 됩니다. controlURL은 상대 경로일 수도 있고 절대 URL일 수도 있으며, 설명 문서에 URLBase가 있으면 그 값을 기준으로 이어 붙여야 해서 URL 조합에서도 자주 버그가 납니다.
SOAP 제어 메시지
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>8080</NewExternalPort>
<NewProtocol>TCP</NewProtocol>
<NewInternalPort>8080</NewInternalPort>
<NewInternalClient>192.168.1.100</NewInternalClient>
<NewEnabled>1</NewEnabled>
<NewPortMappingDescription>My Application</NewPortMappingDescription>
<NewLeaseDuration>0</NewLeaseDuration>
</u:AddPortMapping>
</s:Body>
</s:Envelope>
IGD: 포트 포워딩
NewRemoteHost를 비우면 “모든 외부 주소에서의 접속”을 허용한다는 뜻이고, NewLeaseDuration이 0이면 만료 없는 영구 매핑입니다. 영구 매핑은 애플리케이션이 비정상 종료되어 DeletePortMapping을 부르지 못하면 공유기를 재부팅할 때까지(또는 재부팅 후에도 설정을 저장하는 공유기라면 계속) 포트가 열린 채 남습니다. IGD v2 사양은 이 문제 때문에 lease 0을 최대값(7일)으로 바꿔 처리하도록 권장하고, 일부 공유기는 lease 0 요청을 725 OnlyPermanentLeasesSupported나 그 반대의 에러로 거부하기도 합니다. 그래서 실무에서는 1시간 정도의 lease를 주고 주기적으로 갱신하는 방식(아래 “Lease Duration 관리”)을 씁니다.
IGD란?
IGD(Internet Gateway Device)는 UPnP의 핵심 서비스로, NAT 라우터에서 포트 포워딩을 자동으로 설정합니다.
포트 포워딩 흐름
flowchart TB
subgraph Internal[내부 네트워크 192.168.1.0/24]
App["Application\n192.168.1.100:8080"]
end
subgraph Router[NAT Router]
NAT[NAT Table]
UPnP[UPnP IGD Service]
end
subgraph External[외부 인터넷]
Client[External Client]
end
App -->|1. AddPortMapping\nExternal: 8080\nInternal: 192.168.1.100:8080| UPnP
UPnP -->|2. NAT 규칙 추가| NAT
Client -->|3. Request to\nPublic_IP:8080| NAT
NAT -->|4. Forward to\n192.168.1.100:8080| App
App -->|5. Response| NAT
NAT -->|6. Response| Client
Python으로 포트 포워딩 구현
import requests
import xml.etree.ElementTree as ET
from urllib.parse import urlparse
class UPnPClient:
def __init__(self):
self.gateway_url = None
self.control_url = None
self.service_type = None
def discover_gateway(self):
"""UPnP 게이트웨이 발견"""
devices = discover_upnp_devices() # 이전 예제 함수
for device in devices:
try:
# Description XML 가져오기
response = requests.get(device['location'], timeout=5)
root = ET.fromstring(response.content)
# IGD 서비스 찾기
ns = {'upnp': 'urn:schemas-upnp-org:device-1-0'}
services = root.findall('.//upnp:service', ns)
for service in services:
service_type = service.find('upnp:serviceType', ns)
if service_type is not None and 'WANIPConnection' in service_type.text:
self.gateway_url = device['location']
self.service_type = service_type.text
control_url = service.find('upnp:controlURL', ns).text
parsed = urlparse(device['location'])
self.control_url = f"{parsed.scheme}://{parsed.netloc}{control_url}"
return True
except Exception as e:
continue
return False
def add_port_mapping(self, external_port, internal_port, internal_ip, protocol='TCP', description='UPnP Mapping', lease_duration=0):
"""포트 매핑 추가"""
if not self.control_url:
raise Exception("Gateway not discovered")
soap_body = f'''<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:AddPortMapping xmlns:u="{self.service_type}">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>{external_port}</NewExternalPort>
<NewProtocol>{protocol}</NewProtocol>
<NewInternalPort>{internal_port}</NewInternalPort>
<NewInternalClient>{internal_ip}</NewInternalClient>
<NewEnabled>1</NewEnabled>
<NewPortMappingDescription>{description}</NewPortMappingDescription>
<NewLeaseDuration>{lease_duration}</NewLeaseDuration>
</u:AddPortMapping>
</s:Body>
</s:Envelope>'''
headers = {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPAction': f'"{self.service_type}#AddPortMapping"'
}
response = requests.post(self.control_url, data=soap_body, headers=headers)
return response.status_code == 200
def delete_port_mapping(self, external_port, protocol='TCP'):
"""포트 매핑 삭제"""
soap_body = f'''<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:DeletePortMapping xmlns:u="{self.service_type}">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>{external_port}</NewExternalPort>
<NewProtocol>{protocol}</NewProtocol>
</u:DeletePortMapping>
</s:Body>
</s:Envelope>'''
headers = {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPAction': f'"{self.service_type}#DeletePortMapping"'
}
response = requests.post(self.control_url, data=soap_body, headers=headers)
return response.status_code == 200
def get_external_ip(self):
"""외부 IP 주소 조회"""
soap_body = f'''<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetExternalIPAddress xmlns:u="{self.service_type}">
</u:GetExternalIPAddress>
</s:Body>
</s:Envelope>'''
headers = {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPAction': f'"{self.service_type}#GetExternalIPAddress"'
}
response = requests.post(self.control_url, data=soap_body, headers=headers)
if response.status_code == 200:
root = ET.fromstring(response.content)
ns = {'s': 'http://schemas.xmlsoap.org/soap/envelope/'}
ip_elem = root.find('.//NewExternalIPAddress')
return ip_elem.text if ip_elem is not None else None
return None
def list_port_mappings(self):
"""현재 포트 매핑 목록 조회"""
mappings = []
index = 0
while True:
soap_body = f'''<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetGenericPortMappingEntry xmlns:u="{self.service_type}">
<NewPortMappingIndex>{index}</NewPortMappingIndex>
</u:GetGenericPortMappingEntry>
</s:Body>
</s:Envelope>'''
headers = {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPAction': f'"{self.service_type}#GetGenericPortMappingEntry"'
}
response = requests.post(self.control_url, data=soap_body, headers=headers)
if response.status_code != 200:
break
root = ET.fromstring(response.content)
mapping = {
'external_port': root.find('.//NewExternalPort').text,
'internal_ip': root.find('.//NewInternalClient').text,
'internal_port': root.find('.//NewInternalPort').text,
'protocol': root.find('.//NewProtocol').text,
'description': root.find('.//NewPortMappingDescription').text,
}
mappings.append(mapping)
index += 1
return mappings
# 사용 예시
upnp = UPnPClient()
if upnp.discover_gateway():
print("✅ UPnP Gateway found!")
# 외부 IP 조회
external_ip = upnp.get_external_ip()
print(f"External IP: {external_ip}")
# 포트 포워딩 추가
if upnp.add_port_mapping(8080, 8080, '192.168.1.100', description='My Web Server'):
print("✅ Port 8080 opened")
# 현재 매핑 목록
mappings = upnp.list_port_mappings()
for m in mappings:
print(f" {m['external_port']} -> {m['internal_ip']}:{m['internal_port']} ({m['protocol']})")
# 포트 포워딩 삭제
upnp.delete_port_mapping(8080)
print("✅ Port 8080 closed")
else:
print("❌ No UPnP gateway found")
SOAP 요청이 실패하면 공유기는 보통 HTTP 500과 함께 <UPnPError> 안에 errorCode를 돌려주므로, status_code == 200만 보지 말고 본문의 에러 코드를 로그에 남겨야 원인을 알 수 있습니다. 자주 보는 코드는 718 ConflictInMappingEntry(같은 외부 포트가 다른 내부 주소에 이미 매핑됨), 606 Action not authorized(공유기 설정에서 UPnP 제어가 막힘), 713 SpecifiedArrayIndexInvalid(GetGenericPortMappingEntry의 인덱스 끝, 목록 조회가 끝났다는 정상 신호), 402 Invalid Args(인자 형식 오류) 등입니다. 또 SOAPAction 헤더의 값은 반드시 큰따옴표로 감싸야 하는데, 이를 빠뜨리면 요청을 거부하는 공유기가 있어 위 코드처럼 '"...#AddPortMapping"' 형태로 보냅니다.
GetExternalIPAddress가 돌려준 주소가 100.64.x.x~100.127.x.x, 10.x, 192.168.x처럼 사설·CGNAT 대역이라면 포트 매핑이 성공해도 외부에서 접속할 수 없습니다. 통신사가 공유기 앞단에 또 하나의 NAT(Carrier-Grade NAT)를 두었거나, 통신사 모뎀과 내 공유기가 이중 NAT를 이루고 있는 경우로, UPnP는 바로 위의 공유기 하나에만 매핑을 만들기 때문입니다. 저도 “매핑은 성공인데 외부에서 절대 안 붙는다”는 상황을 한참 디버깅한 끝에 외부 IP가 CGNAT 대역인 것을 확인한 적이 있는데, 이 경우는 UPnP로 해결할 수 없고 모뎀을 브리지 모드로 바꾸거나 릴레이·VPN 같은 대안을 써야 합니다.
실전 구현
Node.js로 UPnP 포트 매핑
const natUpnp = require('nat-upnp');
const client = natUpnp.createClient();
// 포트 매핑 추가
client.portMapping({
public: 8080,
private: 8080,
ttl: 3600, // 1시간
description: 'My Node.js Server'
}, (err) => {
if (err) {
console.error('❌ Port mapping failed:', err);
return;
}
console.log('✅ Port 8080 opened');
// 외부 IP 조회
client.externalIp((err, ip) => {
if (!err) {
console.log(`🌐 External IP: ${ip}`);
console.log(`🔗 Access at: http://${ip}:8080`);
}
});
});
// 프로세스 종료 시 포트 매핑 삭제
process.on('SIGINT', () => {
client.portUnmapping({ public: 8080 }, (err) => {
console.log('✅ Port mapping removed');
process.exit(0);
});
});
nat-upnp 패키지는 오랫동안 유지보수가 멈춰 있어, 새 프로젝트라면 아래 “라이브러리 및 도구”에 적은 대안을 검토하는 편이 좋습니다. SIGINT 핸들러에서 매핑을 지우는 것도 kill -9나 크래시에는 동작하지 않으므로, 여기서 ttl: 3600처럼 lease를 짧게 준 것이 최후의 안전장치 역할을 합니다.
Go로 UPnP 구현
package main
import (
"fmt"
"github.com/huin/goupnp/dcps/internetgateway2"
)
func main() {
// IGD 클라이언트 찾기
clients, _, err := internetgateway2.NewWANIPConnection1Clients()
if err != nil || len(clients) == 0 {
fmt.Println("❌ No UPnP gateway found")
return
}
client := clients[0]
// 외부 IP 조회
externalIP, err := client.GetExternalIPAddress()
if err == nil {
fmt.Printf("🌐 External IP: %s\n", externalIP)
}
// 포트 매핑 추가
err = client.AddPortMapping(
"", // NewRemoteHost (빈 문자열 = 모든 호스트)
8080, // NewExternalPort
"TCP", // NewProtocol
8080, // NewInternalPort
"192.168.1.100", // NewInternalClient
true, // NewEnabled
"My Go Server", // NewPortMappingDescription
0, // NewLeaseDuration (0 = 무제한)
)
if err != nil {
fmt.Printf("❌ Port mapping failed: %v\n", err)
return
}
fmt.Println("✅ Port 8080 opened")
// 포트 매핑 삭제
defer func() {
err := client.DeletePortMapping("", 8080, "TCP")
if err == nil {
fmt.Println("✅ Port mapping removed")
}
}()
// 서버 실행...
}
이 Go 예제는 WANIPConnection1 클라이언트만 찾기 때문에 PPPoE 공유기(WANPPPConnection1)나 IGD v2 전용 공유기(WANIPConnection2)에서는 “No UPnP gateway found”가 나옵니다. goupnp 문서의 예제처럼 세 종류를 모두 찾아보고 먼저 발견된 클라이언트를 쓰는 방식이 실전에서 더 잘 동작합니다. 또 defer로 매핑을 지우는 코드는 main이 정상 반환할 때만 실행되고 os.Exit나 시그널 종료에서는 실행되지 않으므로, signal.NotifyContext로 종료 신호를 받아 정리하는 구조와 함께 쓰는 것이 좋습니다.
C++로 UPnP 구현 (miniupnpc 라이브러리)
#include <miniupnpc/miniupnpc.h>
#include <miniupnpc/upnpcommands.h>
#include <iostream>
#include <string>
class UPnPManager {
private:
struct UPNPUrls urls;
struct IGDdatas data;
char lanaddr[64];
public:
UPnPManager() {
memset(&urls, 0, sizeof(urls));
memset(&data, 0, sizeof(data));
memset(lanaddr, 0, sizeof(lanaddr));
}
~UPnPManager() {
FreeUPNPUrls(&urls);
}
bool discover() {
struct UPNPDev* devlist = upnpDiscover(
2000, // 타임아웃 (ms)
nullptr, // 멀티캐스트 인터페이스
nullptr, // minissdpd 소켓 경로
0, // 로컬 포트
0, // IPv6
2, // TTL
nullptr // 에러 코드
);
if (!devlist) {
std::cerr << "❌ No UPnP devices found" << std::endl;
return false;
}
int result = UPNP_GetValidIGD(devlist, &urls, &data, lanaddr, sizeof(lanaddr));
freeUPNPDevlist(devlist);
if (result == 1) {
std::cout << "✅ Found valid IGD: " << urls.controlURL << std::endl;
std::cout << "🏠 LAN address: " << lanaddr << std::endl;
return true;
}
return false;
}
std::string getExternalIP() {
char externalIP[40];
int result = UPNP_GetExternalIPAddress(
urls.controlURL,
data.first.servicetype,
externalIP
);
if (result == UPNPCOMMAND_SUCCESS) {
return std::string(externalIP);
}
return "";
}
bool addPortMapping(int externalPort, int internalPort, const std::string& protocol = "TCP") {
int result = UPNP_AddPortMapping(
urls.controlURL,
data.first.servicetype,
std::to_string(externalPort).c_str(),
std::to_string(internalPort).c_str(),
lanaddr,
"My C++ Application",
protocol.c_str(),
nullptr, // RemoteHost
"0" // LeaseDuration (0 = 무제한)
);
return result == UPNPCOMMAND_SUCCESS;
}
bool deletePortMapping(int externalPort, const std::string& protocol = "TCP") {
int result = UPNP_DeletePortMapping(
urls.controlURL,
data.first.servicetype,
std::to_string(externalPort).c_str(),
protocol.c_str(),
nullptr // RemoteHost
);
return result == UPNPCOMMAND_SUCCESS;
}
void listPortMappings() {
int index = 0;
char extPort[6];
char intClient[40];
char intPort[6];
char protocol[4];
char desc[80];
char enabled[4];
char rHost[64];
char duration[16];
std::cout << "\n📋 Current Port Mappings:" << std::endl;
while (true) {
int result = UPNP_GetGenericPortMappingEntry(
urls.controlURL,
data.first.servicetype,
std::to_string(index).c_str(),
extPort, intClient, intPort,
protocol, desc, enabled, rHost, duration
);
if (result != UPNPCOMMAND_SUCCESS) {
break;
}
std::cout << " " << extPort << " (" << protocol << ") -> "
<< intClient << ":" << intPort
<< " [" << desc << "]" << std::endl;
index++;
}
}
};
// 사용 예시
int main() {
UPnPManager upnp;
if (!upnp.discover()) {
std::cerr << "❌ Failed to discover UPnP gateway" << std::endl;
return 1;
}
// 외부 IP 조회
std::string externalIP = upnp.getExternalIP();
std::cout << "🌐 External IP: " << externalIP << std::endl;
// 포트 포워딩 추가
if (upnp.addPortMapping(8080, 8080)) {
std::cout << "✅ Port 8080 opened" << std::endl;
std::cout << "🔗 Access at: http://" << externalIP << ":8080" << std::endl;
}
// 현재 매핑 목록
upnp.listPortMappings();
// 서버 실행...
std::cout << "\nPress Enter to close port and exit..." << std::endl;
std::cin.get();
// 포트 포워딩 삭제
upnp.deletePortMapping(8080);
std::cout << "✅ Port 8080 closed" << std::endl;
return 0;
}
miniupnpc의 C API는 버전에 따라 시그니처가 바뀌어 왔다는 점을 알아 두세요. 특히 UPNP_GetValidIGD는 2.2.8 버전에서 WAN 주소를 돌려받는 인자가 추가되어, 오래된 예제를 최신 라이브러리로 빌드하면 인자 개수가 맞지 않는다는 컴파일 에러가 납니다. 반환값도 “연결된 IGD를 찾음”, “IGD는 있지만 연결되지 않음” 등으로 나뉘므로 1만 성공으로 볼지 결정해야 하고, MINIUPNPC_API_VERSION 매크로로 버전을 분기하는 코드가 흔합니다. char protocol[4]처럼 고정 크기 버퍼를 넘기는 API라서 버퍼 크기는 헤더 주석에 적힌 최소 크기를 지켜야 합니다.
보안 이슈
UPnP의 보안 취약점
1. 인증 없음
flowchart LR
Malware[악성 프로그램] -->|AddPortMapping| Router[라우터]
Router -->|포트 오픈| Internet[인터넷]
Internet -->|공격| Malware
style Malware fill:#ff6b6b
style Internet fill:#ff6b6b
문제: 내부 네트워크의 모든 기기가 인증 없이 포트를 열 수 있습니다.
UPnP IGD의 보안 모델은 “LAN 안의 기기는 모두 믿는다”입니다. 그래서 브라우저 확장, 불법 소프트웨어에 섞인 악성코드, 펌웨어가 오래된 IP 카메라 중 하나만 감염되어도 공유기에 포트 매핑을 요청해 외부에서 내부로 들어오는 통로를 만들 수 있습니다. NewInternalClient에 요청한 기기 자신이 아닌 다른 내부 기기의 주소를 넣어도 받아 주는 공유기가 많아, 감염된 PC 한 대가 NAS나 프린터의 관리 포트를 외부에 여는 것도 가능합니다. 안전한 공유기 구현(miniupnpd의 기본 설정 등)은 요청자와 내부 클라이언트 주소가 같을 때만 허용하고 1024 미만 포트를 막는 규칙(secure_mode, 허용 목록)을 제공하므로, 공유기 설정 화면에서 이런 옵션이 있는지 확인해 보는 것이 좋습니다.
2. WAN에 노출된 UPnP와 SSDP 파서 취약점
# 취약한 라우터는 외부(WAN)에서 오는 SSDP/SOAP 요청에도 응답
# → 인터넷 어디서든 포트 매핑 추가, 또는 파서 버그를 이용한 원격 코드 실행
2013년 Rapid7의 조사는 인터넷에서 SSDP 요청에 응답하는 기기가 수천만 대에 이른다고 보고했고, 그중 상당수가 libupnp의 버퍼 오버플로(CVE-2012-5958 등)나 MiniUPnPd의 결함(CVE-2013-0229 등)을 가진 오래된 스택을 쓰고 있었습니다. 원래 LAN에서만 들어야 할 UPnP를 WAN 인터페이스에서도 듣도록 만든 펌웨어가 원인입니다. 2020년에 공개된 CallStranger(CVE-2020-12695)는 GENA 이벤트 구독의 CALLBACK URL을 임의 주소로 지정해, UPnP 기기를 데이터 유출이나 DDoS·내부망 스캔의 중계기로 쓸 수 있는 설계 수준의 문제였습니다.
3. SSDP 증폭 공격
공격자 → 피해자 IP로 스푸핑한 M-SEARCH 전송 (작은 UDP 패킷)
↓
인터넷에 노출된 수많은 UPnP 기기 → 피해자에게 200 OK 응답 (훨씬 큰 패킷, ssdp:all이면 여러 개)
↓
피해자 서버 → DDoS
UDP는 출발지 주소를 위조할 수 있고, ST: ssdp:all 요청 하나에 기기가 서비스마다 여러 개의 응답을 보내므로 작은 요청이 몇십 배의 트래픽으로 불어납니다. 이 공격이 가능한 것도 결국 UDP 1900이 인터넷 쪽에 열려 있기 때문이라, 방어의 첫 단계는 WAN에서 UDP 1900을 받지 않는 것입니다.
보안 대책
1. 라우터 설정
# ✅ UPnP를 내부 네트워크로만 제한
# 라우터 관리 페이지에서:
# - UPnP 활성화: Yes
# - 외부 인터넷에서 UPnP 접근: No
# - UPnP 로깅: Yes
2. 애플리케이션 레벨 검증
import ipaddress
def safe_add_port_mapping(upnp, port, internal_ip):
"""안전한 포트 매핑"""
# 1. 포트 범위 검증
if port < 1024 or port > 65535:
raise ValueError("Invalid port range")
# 2. 내부 IP 검증 (172.16.0.0/12 등 사설 대역을 정확히 판별)
if not ipaddress.ip_address(internal_ip).is_private:
raise ValueError("Invalid internal IP")
# 3. 짧은 Lease Duration 설정
result = upnp.add_port_mapping(
port, port, internal_ip,
lease_duration=3600 # 1시간 후 자동 만료
)
return result
3. 방화벽 규칙
# Linux iptables로 SSDP 멀티캐스트 제한
iptables -A INPUT -p udp --dport 1900 -m state --state NEW -j DROP
iptables -A INPUT -p udp --dport 1900 -m state --state ESTABLISHED,RELATED -j ACCEPT
내부 IP를 문자열 접두사로 검사하던 원래 방식은 172.로 시작하는 모든 주소(예: 공인 대역인 172.217.x.x)를 통과시키는 버그가 있어, 사설 대역 172.16.0.0/12만 허용하려면 ipaddress 모듈로 판별하는 것이 정확합니다. 다만 이 검증은 “우리 애플리케이션이 실수로 이상한 매핑을 만들지 않게” 하는 자기 방어일 뿐, 악성 프로그램은 이 함수를 거치지 않고 공유기에 직접 요청하므로 공유기 쪽 보안을 대신하지 못합니다.
보안 체크리스트
- UPnP를 외부 인터넷에 노출하지 않기
- 라우터 펌웨어를 최신 버전으로 유지
- 불필요한 경우 UPnP 비활성화
- 포트 매핑에 Lease Duration 설정
- 정기적으로 포트 매핑 목록 확인
- UPnP 로그 모니터링
대안 기술
NAT Traversal 기술 비교
flowchart TB
Problem["NAT 문제:\n외부에서 내부 접근 불가"]
UPnP["UPnP\n자동 포트 포워딩"]
Manual["수동 포트 포워딩\n라우터 설정"]
STUN["STUN/TURN\nWebRTC"]
Relay["릴레이 서버\n중계"]
VPN["VPN\n터널링"]
Problem --> UPnP
Problem --> Manual
Problem --> STUN
Problem --> Relay
Problem --> VPN
UPnP --> UPnP_Pro["장점: 자동화\n단점: 보안 위험"]
Manual --> Manual_Pro["장점: 안전\n단점: 수동 설정"]
STUN --> STUN_Pro["장점: P2P 직접 연결\n단점: 일부 NAT 실패"]
Relay --> Relay_Pro["장점: 항상 작동\n단점: 서버 비용"]
VPN --> VPN_Pro["장점: 암호화\n단점: 복잡한 설정"]
기술별 비교
| 기술 | 자동화 | 보안 | 성능 | 복잡도 | 사용 사례 |
|---|---|---|---|---|---|
| UPnP | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ | ⭐ | 게임, P2P |
| 수동 포트 포워딩 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | 서버 호스팅 |
| STUN/TURN | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | WebRTC, 화상통화 |
| 릴레이 서버 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | 채팅, 파일 전송 |
| VPN | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | 원격 근무 |
STUN/TURN (WebRTC)
// WebRTC ICE 설정
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, // STUN
{
urls: 'turn:turn.example.com:3478', // TURN
username: 'user',
credential: 'pass'
}
]
};
const peerConnection = new RTCPeerConnection(configuration);
// ICE 후보 수집
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
console.log('ICE Candidate:', event.candidate.candidate);
// srflx (STUN), relay (TURN), host (로컬)
}
};
표의 별점은 대략적인 경향일 뿐입니다. 특히 STUN은 “내 공인 주소와 포트가 무엇으로 보이는지”를 알려 주는 도구라 포트를 열어 주지는 않고, 양쪽 NAT가 모두 포트 매핑을 예측하기 어려운 방식(symmetric NAT)이면 홀 펀칭이 실패해 결국 TURN 릴레이로 넘어갑니다. 모바일 네트워크처럼 CGNAT 뒤에 있는 환경이 늘면서, 화상 통화나 게임 매치메이킹 서비스는 UPnP를 “되면 좋은 최적화”로만 쓰고 TURN·릴레이를 기본 경로로 설계하는 경우가 많습니다.
NAT-PMP (UPnP 대안)
import natpmp
# NAT-PMP는 Apple이 개발한 UPnP 대안
# 더 간단하고 안전함
gateway = natpmp.get_gateway_addr()
print(f"Gateway: {gateway}")
# 포트 매핑 추가
response = natpmp.map_port(
natpmp.NATPMP_PROTOCOL_TCP,
8080, # private port
8080, # public port
3600 # lifetime (seconds)
)
print(f"External IP: {response.public_ip_address}")
print(f"Mapped port: {response.public_port}")
NAT-PMP(RFC 6886)는 XML과 SOAP 없이 UDP 5351 포트로 고정 크기 바이너리 메시지를 주고받고, 매핑에 반드시 수명이 있으며 게이트웨이 주소(기본 라우트)로만 요청을 보내므로 구현이 훨씬 단순합니다. 후속 표준인 PCP(Port Control Protocol, RFC 6887)는 NAT-PMP와 호환되면서 IPv6와 통신사 CGNAT 장비까지 대상으로 확장한 프로토콜입니다. 인증이 없다는 점은 UPnP와 같으므로 “더 안전하다”는 말은 공격 표면(복잡한 XML 파서, WAN 노출 사례)이 작다는 의미로 이해하는 것이 정확합니다. 사용하는 공유기가 NAT-PMP/PCP를 지원하는지는 제조사마다 달라, 라이브러리들은 보통 PCP → NAT-PMP → UPnP 순으로 시도합니다.
실전 시나리오
시나리오 1: P2P 파일 공유 애플리케이션
import socket
import threading
class P2PFileSharing:
def __init__(self, port=8080):
self.port = port
self.upnp = UPnPClient()
self.external_ip = None
def start(self):
# 1. UPnP 게이트웨이 발견
if not self.upnp.discover_gateway():
print("⚠️ UPnP not available, using manual port forwarding")
return False
# 2. 로컬 IP 가져오기 (gethostbyname은 127.0.1.1을 돌려줄 수 있음 → 아래 get_local_ip 참고)
local_ip = socket.gethostbyname(socket.gethostname())
# 3. 포트 포워딩 설정
if self.upnp.add_port_mapping(self.port, self.port, local_ip):
print(f"✅ Port {self.port} opened via UPnP")
else:
print("❌ Failed to open port")
return False
# 4. 외부 IP 조회
self.external_ip = self.upnp.get_external_ip()
print(f"🌐 Share this address: {self.external_ip}:{self.port}")
# 5. 서버 시작
self.start_server()
return True
def start_server(self):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', self.port))
server.listen(5)
print(f"🚀 P2P server listening on port {self.port}")
while True:
client, addr = server.accept()
thread = threading.Thread(target=self.handle_client, args=(client,))
thread.start()
def handle_client(self, client):
# 파일 전송 로직
pass
def stop(self):
# 포트 포워딩 삭제
self.upnp.delete_port_mapping(self.port)
print("✅ Port mapping removed")
# 사용
app = P2PFileSharing(port=8080)
try:
app.start()
except KeyboardInterrupt:
app.stop()
시나리오 2: 게임 서버 호스팅
const express = require('express');
const natUpnp = require('nat-upnp');
class GameServer {
constructor(port = 7777) {
this.port = port;
this.client = natUpnp.createClient();
this.app = express();
}
async start() {
try {
// UPnP 포트 매핑
await this.openPort();
// Express 서버 시작
this.app.listen(this.port, () => {
console.log(`🎮 Game server running on port ${this.port}`);
});
// 게임 로직
this.setupGameRoutes();
} catch (err) {
console.error('❌ Failed to start server:', err);
}
}
async openPort() {
return new Promise((resolve, reject) => {
this.client.portMapping({
public: this.port,
private: this.port,
ttl: 7200, // 2시간
description: 'Game Server'
}, (err) => {
if (err) {
reject(err);
return;
}
this.client.externalIp((err, ip) => {
if (!err) {
console.log(`✅ Port ${this.port} opened`);
console.log(`🌐 Players can connect to: ${ip}:${this.port}`);
}
resolve();
});
});
});
}
setupGameRoutes() {
this.app.get('/status', (req, res) => {
res.json({ status: 'online', players: 5 });
});
this.app.post('/join', (req, res) => {
// 플레이어 참가 로직
res.json({ success: true });
});
}
async stop() {
return new Promise((resolve) => {
this.client.portUnmapping({ public: this.port }, () => {
console.log('✅ Port mapping removed');
resolve();
});
});
}
}
// 사용
const server = new GameServer(7777);
server.start();
// 종료 처리
process.on('SIGINT', async () => {
await server.stop();
process.exit(0);
});
디버깅 및 문제 해결
UPnP 작동 확인
Linux/Mac
# UPnP 기기 검색
upnpc -l
# 포트 매핑 추가
upnpc -a 192.168.1.100 8080 8080 TCP
# 외부 IP 조회
upnpc -s
# 포트 매핑 삭제
upnpc -d 8080 TCP
Windows PowerShell
# UPnP 서비스 상태 확인
Get-Service -Name "SSDPSRV"
# UPnP 기기 검색 (C# 스크립트)
$upnp = New-Object -ComObject UPnP.UPnPDeviceFinder
$devices = $upnp.FindByType("urn:schemas-upnp-org:device:InternetGatewayDevice:1", 0)
$devices | ForEach-Object { $_.FriendlyName }
일반적인 문제
문제 1: UPnP 기기를 찾을 수 없음
# 해결 방법
# 1. 라우터에서 UPnP 활성화 확인
# 2. 방화벽이 UDP 1900 포트 차단 확인
# 3. 멀티캐스트 라우팅 확인
# 디버그 모드로 실행
import logging
logging.basicConfig(level=logging.DEBUG)
문제 2: 포트 매핑 실패
# 원인:
# - 포트가 이미 사용 중
# - 라우터가 해당 포트 차단
# - Lease Duration 초과
# 해결:
# 1. 기존 매핑 확인 및 삭제
mappings = upnp.list_port_mappings()
for m in mappings:
if m['external_port'] == '8080':
upnp.delete_port_mapping(8080)
# 2. 다른 포트 시도
for port in range(8080, 8090):
if upnp.add_port_mapping(port, 8080, local_ip):
print(f"✅ Opened port {port}")
break
문제 3: 외부에서 접근 불가
# 확인 사항:
# 1. 포트 매핑이 실제로 추가되었는지
mappings = upnp.list_port_mappings()
print(mappings)
# 2. 방화벽이 해당 포트 허용하는지
# Linux:
# sudo ufw allow 8080/tcp
# 3. 애플리케이션이 0.0.0.0에서 리스닝하는지
# ✅ server.listen(8080, '0.0.0.0')
# ❌ server.listen(8080, 'localhost') # 외부 접근 불가
라이브러리 및 도구
Python
pip install miniupnpc
# 사용
import miniupnpc
u = miniupnpc.UPnP()
u.discoverdelay = 200
u.discover()
u.selectigd()
# 외부 IP
print(u.externalipaddress())
# 포트 매핑
u.addportmapping(8080, 'TCP', u.lanaddr, 8080, 'My App', '')
Node.js
npm install nat-upnp
# 또는 더 현대적인 라이브러리
npm install @achingbrain/nat-port-mapper
Go
go get github.com/huin/goupnp
C++
# Linux/Mac
sudo apt-get install libminiupnpc-dev
# CMakeLists.txt
find_package(miniupnpc REQUIRED)
target_link_libraries(myapp miniupnpc)
매핑 수명과 로컬 주소에서 조용히 틀리는 지점
Lease Duration 관리
lease를 짧게 주는 방식은 갱신을 잊으면 서비스가 몇 시간 뒤 조용히 끊긴다는 뜻이기도 합니다. 같은 외부 포트·프로토콜·내부 주소로 AddPortMapping을 다시 호출하면 대부분의 공유기는 기존 항목의 lease만 늘려 주므로, lease의 절반 정도 간격으로 다시 호출하면 됩니다. 이때 스레드를 time.sleep(1800)으로 재우면 종료 시 join()이 최대 30분 동안 막히므로, threading.Event.wait()으로 기다려야 stop()이 즉시 반환됩니다.
import threading
class UPnPKeepAlive:
def __init__(self, upnp, port, local_ip, lease=3600):
self.upnp, self.port, self.local_ip, self.lease = upnp, port, local_ip, lease
self._stop = threading.Event()
self.thread = threading.Thread(target=self._keep_alive, daemon=True)
def start(self):
self.thread.start()
def _keep_alive(self):
while not self._stop.is_set():
self.upnp.add_port_mapping(self.port, self.port, self.local_ip,
lease_duration=self.lease)
self._stop.wait(self.lease / 2) # stop()이 호출되면 바로 깨어남
def stop(self):
self._stop.set()
self.thread.join()
self.upnp.delete_port_mapping(self.port)
갱신 호출이 718 ConflictInMappingEntry로 실패한다면, 그 사이 DHCP로 이 기기의 내부 주소가 바뀌었거나 같은 외부 포트를 다른 기기가 가져간 경우입니다. 이때는 같은 포트를 계속 재시도하지 말고 다른 외부 포트로 매핑한 뒤, 상대에게 알려 주는 주소도 새 포트로 바꿔야 합니다. 그리고 UPnP 탐색이나 매핑이 실패하는 환경(UPnP가 꺼진 공유기, 이중 NAT, 통신사 CGNAT)은 드물지 않으므로, 실패 시 사용자에게 수동 포트 포워딩을 안내하거나 릴레이로 넘어가는 경로를 처음부터 설계에 넣어 두는 편이 낫습니다. 특히 GetExternalIPAddress가 100.64.0.0/10 대역이나 사설 주소를 돌려준다면 공유기 바깥에 NAT가 한 겹 더 있다는 뜻이라, 매핑이 성공해도 외부에서는 닿지 않습니다.
매핑에 넣을 내부 주소 구하기
import socket
def get_local_ip(gateway_ip="8.8.8.8"):
"""gateway_ip로 가는 경로의 출발지 주소 (패킷은 보내지 않음).
VPN이 기본 경로를 잡고 있으면 외부 주소 대신 SSDP 응답
LOCATION URL의 호스트(공유기)를 넘겨야 LAN 주소가 나옵니다."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((gateway_ip, 1900))
return s.getsockname()[0]
finally:
s.close()
socket.gethostbyname(socket.gethostname())은 가장 흔히 보이는 방법이지만, Debian/Ubuntu 계열은 /etc/hosts에 호스트 이름을 127.0.1.1로 등록해 두기 때문에 루프백 주소가 나오고, 네트워크 어댑터가 여러 개인 Windows에서는 VPN이나 가상 어댑터 주소가 나오기도 합니다. 이 주소로 AddPortMapping을 하면 공유기가 거부하거나, 매핑은 되는데 엉뚱한 곳으로 전달됩니다. 위 코드처럼 UDP 소켓을 공유기(또는 아무 외부 주소)로 connect()한 뒤 getsockname()으로 OS가 고른 출발지 주소를 읽는 방법이 이식성이 좋습니다. UDP connect는 패킷을 보내지 않고 라우팅 테이블만 조회하므로 부작용이 없고, 공유기로 실제로 나가는 인터페이스의 주소를 정확히 얻을 수 있습니다. miniupnpc를 쓴다면 UPNP_GetValidIGD가 채워 주는 lanaddr가 같은 역할을 합니다.
자주 묻는 질문 (FAQ)
Q. UPnP로 포트 매핑이 성공했다고 나오는데 외부에서 접속되지 않으면 무엇을 확인하나요?
A. 먼저 list_port_mappings로 매핑이 실제로 라우터에 추가되어 있는지 확인하고, 호스트 방화벽이 해당 포트를 허용하는지 봅니다. 자주 놓치는 원인은 애플리케이션이 localhost에만 바인딩된 경우라서, 외부 접근이 필요하면 0.0.0.0에서 리스닝해야 합니다. 매핑에는 Lease Duration이 있어 시간이 지나면 사라질 수 있으므로 주기적으로 갱신하는 로직도 필요합니다.