Modbus/TCP Security
이 튜토리얼은 같은 PC에서 Modbus/TCP Security 서버와 클라이언트를 실행합니다. 시험용 인증 기관(CA)과 역할이 다른 클라이언트 인증서를 만들고, 서버가 역할에 따라 기능 코드를 허용하거나 거부하는지 확인합니다.
- 역할 확장(OID
1.3.6.1.4.1.50316.802.1)을 넣은 클라이언트 인증서를 만듭니다. - 서버의
roles,null_role정책이 요청마다 적용되는 것을 확인합니다. - 호스트 이름 불일치, 평문 클라이언트, 모드 불일치, 폐기된 인증서가 어떻게 실패하는지 봅니다.
준비물
섹션 제목: “준비물”- Security를 포함한 PDComm Modbus SDK(
capabilities의security가true)와 빠른 시작의 C++ 콘솔 - OpenSSL 3 명령줄 도구.
openssl version이OpenSSL 3.으로 시작해야 합니다. macOS 기본openssl은 LibreSSL이므로 Homebrew의openssl@3를 씁니다. - 터미널 두 개
1. 인증서를 만듭니다
섹션 제목: “1. 인증서를 만듭니다”작업 폴더에 certs 폴더를 만들고 CA를 만듭니다.
mkdir certs && cd certsopenssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 365 -subj "/CN=Modbus Lab CA" \ -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" \ -keyout ca.key -out ca.pem서버 하나와 클라이언트 세 개(reader, operator, 역할 없음)의 인증서를 발급합니다. 모든 인증서의 SAN에 localhost와 127.0.0.1을 넣고, reader와 operator에는 역할 확장을 넣습니다.
for name in server reader operator no-role; do { echo "basicConstraints=critical,CA:FALSE" echo "keyUsage=critical,digitalSignature,keyEncipherment" echo "extendedKeyUsage=serverAuth,clientAuth" echo "subjectAltName=DNS:localhost,IP:127.0.0.1" case "$name" in reader|operator) echo "1.3.6.1.4.1.50316.802.1=ASN1:UTF8String:$name" ;; esac } > "$name.ext" openssl req -new -newkey rsa:2048 -nodes -subj "/CN=$name" -keyout "$name.key" -out "$name.csr" openssl x509 -req -in "$name.csr" -CA ca.pem -CAkey ca.key -CAcreateserial -days 365 -sha256 \ -extfile "$name.ext" -out "$name.pem" cat "$name.pem" ca.pem > "$name-chain.pem"done-extfile로 SAN, 키 용도, 역할 확장을 넣습니다. PDComm은 X.509v3 장치 인증서만 받습니다.*-chain.pem은 자신의 인증서 뒤에 루트 CA를 붙인 파일입니다. PDComm은 체인이 루트 CA로 끝나야 합니다.
역할 확장이 들어갔는지 확인합니다.
openssl x509 -in reader.pem -noout -text | grep -A1 '50316' 1.3.6.1.4.1.50316.802.1: ..reader..은 UTF8String의 형식 바이트가 화면에 보이는 것입니다. 작업 폴더로 돌아갑니다(cd ..). 이후 명령의 /absolute/path/certs는 이 폴더의 절대 경로로 바꿉니다.
2. TLS 서버를 엽니다
섹션 제목: “2. TLS 서버를 엽니다”터미널 A에서 헤드리스 시뮬레이터를 시작하고 보안 서버를 엽니다.
"$MODBUS_SDK/bin/pdcomm-modbus-headless"{"action":"configure","config":{"transport":"tcp","role":"server","address":"127.0.0.1:1802","port":"","unit":1,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000,"security":{"mode":"encrypted","chain":"/absolute/path/certs/server-chain.pem","private_key":"/absolute/path/certs/server.key","trust":"/absolute/path/certs/ca.pem","roles":{"reader":[1,2,3,4],"operator":[3,6,16]},"null_role":[]}}}{"action":"connect"}권한 정책은 다음과 같습니다.
| 클라이언트 | 허용 기능 |
|---|---|
역할 reader |
01, 02, 03, 04 |
역할 operator |
03, 06, 16 |
| 역할 없음 | 없음(null_role이 빈 목록) |
3. 역할별로 요청합니다
섹션 제목: “3. 역할별로 요청합니다”터미널 B에서 C++ 콘솔을 시작합니다.
./build/modbus-tutorial/pdcomm-modbus-tutorialreader로 연결해 읽기와 쓰기를 보냅니다.
{"action":"configure","config":{"transport":"tcp","role":"client","address":"127.0.0.1:1802","port":"","unit":1,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000,"security":{"mode":"encrypted","chain":"/absolute/path/certs/reader-chain.pem","private_key":"/absolute/path/certs/reader.key","trust":"/absolute/path/certs/ca.pem"}}}{"action":"connect"}{"action":"request","pdu":"0300000001"}{"action":"request","pdu":"060000002A"}{"action":"disconnect"}{"ok":true,"result":{"response":[3,2,0,0],"source":"wire","status":"success"}}{"ok":true,"result":{"response":[134,1],"source":"wire","status":"exception"}}reader는 06을 쓸 수 없으므로 예외 01([134,1])입니다. 연결은 유지됩니다.
operator로 바꿔 연결합니다. configure에서 chain과 private_key만 operator-chain.pem, operator.key로 바꿉니다.
{"action":"connect"}{"action":"request","pdu":"060000002A"}{"action":"request","pdu":"0300000001"}{"action":"request","pdu":"0400000001"}{"action":"disconnect"}{"ok":true,"result":{"response":[6,0,0,0,42],"source":"wire","status":"success"}}{"ok":true,"result":{"response":[3,2,0,42],"source":"wire","status":"success"}}{"ok":true,"result":{"response":[132,1],"source":"wire","status":"exception"}}operator는 쓰기와 03은 할 수 있지만 04는 정책에 없으므로 예외 01입니다.
역할 없는 인증서(no-role-chain.pem, no-role.key)로 연결하면 null_role이 빈 목록이므로 03도 [131,1]입니다. 역할이 없어도 CA가 발급한 인증서이므로 TLS 연결 자체는 성공합니다.
4. 연결 실패를 확인합니다
섹션 제목: “4. 연결 실패를 확인합니다”다음은 모두 connect 또는 첫 요청이 실패하는 경우입니다. C++ 콘솔은 표준 오류에 Command error:와 오류 JSON을 출력합니다.
| 바꾼 설정 | 결과 |
|---|---|
클라이언트 security에 "hostname":"plc.example" 추가 |
connect가 Security("Handshake(\"error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed:...\")") |
클라이언트 security를 null로(평문 TCP) |
connect는 성공하지만 request가 Io(ConnectionReset, "Connection reset by peer (os error 54)") |
클라이언트만 "mode":"authentication_only" |
connect가 Security("Handshake(\"...ssl/tls alert handshake failure...SSL alert number 40\")") |
클라이언트 chain을 루트 CA 없는 reader.pem으로 |
connect가 Config("Chain must include its root CA") |
클라이언트 private_key를 다른 인증서의 키로 |
connect가 Crypto(ErrorStack([...reason: "key values mismatch"...])) |
- 서버 인증서의 SAN에 없는 이름으로 접속하면 인증서 검증이 실패합니다. 실제 장비의 IP로 접속한다면
hostname에 그 IP를 넣고, 서버 인증서 SAN에도 같은 IP가 있어야 합니다. - PDComm은 보안 연결이 실패했을 때 평문이나 인증 전용으로 낮추지 않습니다.
5. 인증 전용 모드를 써 봅니다(선택)
섹션 제목: “5. 인증 전용 모드를 써 봅니다(선택)”authentication_only는 암호화하지 않고 인증과 무결성만 제공합니다. 양쪽 모두 바꿔야 합니다. 터미널 A에서 서버를 끊고 mode만 바꿔 다시 엽니다.
{"action":"disconnect"}그다음 2단계의 configure에서 "mode":"authentication_only"로 바꿔 입력하고 connect합니다. 클라이언트도 operator 구성에 "mode":"authentication_only"를 넣어 연결하면 0300000001 요청이 성공합니다. 이 모드는 TLS 1.2의 NULL-SHA256을 쓰므로 RSA 인증서가 필요합니다.
6. 폐기된 인증서를 막습니다(선택)
섹션 제목: “6. 폐기된 인증서를 막습니다(선택)”reader 인증서를 폐기하고 CRL을 만듭니다. certs 폴더에 다음 내용으로 ca.cnf를 만듭니다.
[ca]default_ca = lab[lab]database = index.txtcrlnumber = crlnumbercertificate = ca.pemprivate_key = ca.keydefault_md = sha256default_crl_days = 30cd certstouch index.txt && echo 01 > crlnumberopenssl ca -config ca.cnf -revoke reader.pemopenssl ca -config ca.cnf -gencrl -out ca.crlcd ..서버를 끊고, 2단계의 security에 다음 두 필드를 더해 다시 엽니다.
"crls":["/absolute/path/certs/ca.crl"],"require_revocation":truereader로 연결하면 connect는 성공하지만 첫 요청이 실패합니다. TLS 1.3에서는 서버가 클라이언트 인증서를 연결이 끝난 뒤 검증하기 때문입니다.
Command error: {"error":"Io(Other, \"error:0A000414:SSL routines:ssl3_read_bytes:ssl/tls alert certificate revoked:...SSL alert number 44\")","ok":false}이때 state의 connected는 false입니다. operator로 연결하면 정상입니다. require_revocation을 true로 하면서 crls를 비우면 Config("Revocation checking requires CRLs")로 실패합니다.
7. 정리합니다
섹션 제목: “7. 정리합니다”- 터미널 B:
quit - 터미널 A:
{"action":"shutdown"} - 시험이 끝나면
certs폴더를 지웁니다.
다음 단계
섹션 제목: “다음 단계”- TLS 오류 메시지별 원인은 보안 연결 문제에 있습니다.
- GUI의 Security 탭에서 같은 JSON을 쓰는 방법은 가이드: 시뮬레이터 GUI에 있습니다.