2021년 4월 발생한 23,624건의 개인정보 유출
-> 시정명령, 9억여원의 과징금, 1740만원의 과태료 부과처분
아래 더보기는 사건 자세히 정리
초등 이용자의 개인정보 유출 흔적 감지(2021.4.8)
피심인(심의,의결서에서 칭함, 재판에 대해서는 원고,항소인)에 대한 개인정보 취급, 운영 실태 및 보호법 위반 여부 조사(2021.4.9~2021.9.1)
아래는 조사하며 얻은 행위 사실
가. 개인정보 수집 현황

나. 개인정보 유출 경위
21.4.7 17:30 DB관리자가 비정상 쿼리 발견
21.4.7 23:00 백과 웹서버에 웹셸 및 터널링 프로그램 존재 확인
21.4.8 13:50 한국인터넷진흥원에 개인정보 유출 신고
21.4.8 17:20 유출 대상자들에게 이메일, 문자로 유출 가능성 통지, 홈페이지 공지사항 게시
제1심 판결 서울행정법원 2023.1.13 선고 2022구합61564 판결
제2심 판결 서울고등법원 2023.11.2 선고 2023누34486 판결
제2심에 관해서.. 과징금부과처분 취소청구의 소
원고, 항소인: 주식회사 A, 천재교과서
피고, 피항소인: 개인정보보호위원회
청구 취지 및 항소 취지
제 1심 판결 취소
2021.10.27 의결 B로, 한 별지 1 처분내역 기재 각 처분 중 제2의 가,다,라.항 과징금납부명령 부분을 취소
아래 더보기는 2021.10.27 개인정보보호위원회 심의,의결 정리
안건번호 제2021 - 017 - 265호
주문 정리
1. 피심인에 대하여 다음과 같이 시정조치를 명한다.
2. 피심인에 대하여 다음과 같이 과징금과 과태료를 부과한다.
3. 피심인의 법 위반행위 내용 및 결과를 개인정보보호위원회 홈페이지에 공표한다.
[의결서(제2021-017-264~272호).pdf](https://blog.kakaocdn.net/dna/LzrqN/btsHuO4cvM7/AAAAAAAAAAAAAAAAAAAAABTzQR_o6MX14c78UIBliviJbUE7AR0puINfkUIzbP6i/%EC%9D%98%EA%B2%B0%EC%84%9C(%EC%A0%9C2021-017-264~272%ED%98%B8).pdf?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1769871599&allow_ip=&allow_referer=&signature=7BeVwVyC%2BUprxUNdJYVl5Fic%2B6M%3D&attach=1&knm=tfile.pdf)
피고의 원고에 대한 처분 사유
- 개인정보처리시스템에 대한 접근통제를 소홀히 한 행위
- 개인정보처리시스템의 접속기록 보관 및 점검을 소홀히 한 행위
- 개인정보의 유출 사실을 추가 통지하지 않은 행위
과징금의 산정 근거
1) 기준 금액의 산정
가) 관련 매출액의 산정

나) 중대성의 판단
- 개인정보 보호법 제29조의 안전조치의무 근거, 피고는 원고에게 중과실 있다고 봄
- 과징금 부과기준 제5조 제3항 본문 근거, 원고의 위반행위 ‘중대한 위반행위’
다) 기준금액의 산출
개인정보 보호법 시행령 및 과징금 부과기준 제3조 제1항 근거,
관련 매출액(53,771,039,000원) X ‘중대한 위반행위’에 해당하는 부과 기분율 21/1000
2) 필수적 가중 및 감경
가) 과징금 부과기준 제6조, 제7조 근거,
장기 위반행위(2년 초과)로 인해 50% 가산(564,594,000원)
나) 원고가 최근 3년 이내 개인정보 보호법 제39조의15 제1항 행위들로 과징금 부과처분 없음으로 인해
50% 감경(564,594,000원)
3) 추가적 가중 및 감경
과징금 부과기준 제8조에 근거,
- 원고가 조사에 적극 협력
- 개인정보유출사실을 자진 신고한 점
20% 감경(225,839,000원)
원고의 주장
1) 처분 사유의 부존재
가) 제1처분사유의 부존재: Any->Any 허용 규칙만으로 접근 통제 소홀히 운영했다고는 볼 수 없음
나) 제2처분사유의 부존재: 유출사고와 원고의 위반 행위 사이에 관련성 인정될 수 없음
2) 재량권의 일탈, 남용
가) 기준금액 산출 위법: 중대한 위반행위 인정할 수 없음
나) 필수적 가중 위법: 장기 위반행위 인정할 수 없음
다) 비례의 원칙 및 평등의 원칙 위반: 과징금납부명령 지나치다
인정사실
1) 원고와 E의 시스템 운영 현황
원고와 E는 각각 G와의 원격보안관제 서비스 계약, 원고가 운영한 C 서비스는 E이 운영한 F와 일부 인프라 공유
C서비스(원고)의 2차 방화벽에 F서비스(G) 접근 권한을 가진 IP도 C서비스 DB에 접속할 수 있었음 - Any->Any 허용 규칙
2) 유출사고 경위와 과정
F 웹 서버에 웹쉘 업로드 -> 웹쉘에 접속하여 터널링 프로그램 업로드 -> C서비스 DB에 직접 접속 후 개인정보 외부 전송
3) 원고의 후속조치
원고 DB 접근제어 계정 이외에 C의 DB 접근 불가능 - Any->Any 차단 규칙
판단
1) 처분사유의 존재 여부
가) 관련 규정과 법리
(1) 개인정보 보호법 제29조의 위임에 따라
개인정보 보호법 시행령 제48조의2 제1항 :
제2호 (나)목에서 ‘개인정보에 대한 불법적인 접근을 차단하기 위한 조치’
제3호에서 ‘접속기록의 위조 변조 방지를 위한 조치’
(2) 위반 여부 판단 시 고려할 점
… 당시 보편적으로 알려져 있는 정보보안의 기술 수준
… 정보통신서비스 제공자의 업종 영업규모와 정보통신서비스 제공자가 취하고 있던 저체적인 보안조치의 내용
… 정보보안에 필요한 경제적 비용 및 효용의 정도
… 해킹기술의 수준과 정보보안기술의 발전 정도에 따른 피해 발생의 회피 가능성
… 정보통신서비스 제공자가 수집한 개인정보의 내용과.. 등등
나) 제1처분사유 존재 여부: 웹셀을 통한 정보유출 충분히 방지할 수 있었음 -> 개인정보 보호법령과 보호조치 기준 제4조 제5항 위반
다) 제2처분사유 존재 여부: 관련성 충분히 인정됨
2) 재량권의 일탈, 남용 여부
가) 기준금액 산출 위법 여부: 중과실이 있다고 봄이 타당
나) 필수적 가중 위법 여부: 장기 위반행위에 해당한다고 봄이 타당
다) 비례의 원칙 및 평등의 원칙 위반 여부: 지나치게 가혹하다 보기 어렵다
소결
과징금납부명령 적법, 원고의 주장 모두 받아들일 수 없다.
int __fastcall main(int argc, const char **argv, const char **envp)
{
int v3; // ebx
int seed; // [rsp+4h] [rbp-6Ch]
int i; // [rsp+8h] [rbp-68h]
int j; // [rsp+Ch] [rbp-64h]
char s[72]; // [rsp+10h] [rbp-60h] BYREF
unsigned __int64 v9; // [rsp+58h] [rbp-18h]
v9 = __readfsqword(0x28u);
printf("Let me check your flag: ");
__isoc99_scanf("%s", s);
if ( strlen(s) != 63 )
no();
seed = 1;
for ( i = 0; i < strlen(s); ++i )
seed *= s[i];
srand(seed);
for ( j = 0; j < strlen(s); ++j )
{
v3 = s[j];
if ( ((rand() % 256) ^ v3) != target[j] )
no();
}
puts("Correct!");
return 0;
}
s 길이 63
seed 값 계산 가능..? -> random에서 쓰는 문자열(ascii) seed는 0~256
v3는 s[j]
(rand()%256)^v3값이 target[j]의 값과 같아야 함
이때 rand는 seed가 있으므로 규칙적으로 정해짐
33h, 84h, 3Dh, 3Fh, 2Ah, 93h, 7Bh, 82h, 1Ah, 0ACh, 8Eh //11
0F4h, 0B1h, 0CBh, 8Dh, 21h, 0Eh, 0B7h, 67h, 96h, 2Ch //10
81h, 0D3h, 0BCh, 29h, 6Ch, 4Bh, 0Dh, 0EDh, 0Fh, 0Dh //10
0EEh, 56h, 40h, 52h, 0D5h, 5, 6Dh, 90h, 3Eh, 7Ah, 1Bh //11
69h, 23h, 1Fh, 0B6h, 1Dh, 0BCh, 98h, 0D1h, 0A6h, 83h //10
0E9h, 0EBh, 13h, 21h, 3Dh, 0F8h, 2Bh, 79h, 53h, 4Fh //10
0A1h //1
이게 target…
#include <iostream>
#include <random>
using namespace std;
int main() {
unsigned char target[63] = { 0x33, 0x84, 0x3d, 0x3f, 0x2a, 0x93, 0x7b, 0x82, 0x1a, 0xac, 0x8e,
0xf4, 0xb1, 0xcb, 0x8d, 0x21, 0x0e, 0xb7, 0x67, 0x96, 0x2c,
0x81, 0xd3, 0xbc, 0x29, 0x6c, 0x4b, 0x0d, 0x00, 0xed, 0xfd,
0xee, 0x56, 0x40, 0x52, 0xd5, 0x05, 0x6d, 0x90, 0x3e, 0x7a, 0x1b,
0x69, 0x23, 0x1f, 0xb6, 0x1d, 0xbc, 0x98, 0xd1, 0xa6, 0x83,
0xe9, 0xeb, 0x13, 0x21, 0x3d, 0xf8, 0x2b, 0x79, 0x53, 0x4f, 0xa1 };
unsigned long long seedst = 0;
unsigned long long seeden = 256;
bool found = true;
unsigned long long seed;
char s[64] = { 0, };
for (unsigned long long i = seedst; i <= seeden; ++i) {
seed = i;
srand(i);
for (int j = 0; j < 63; ++j) {
int randd = rand() % 256;
s[j] = randd ^ target[j];
if (s[j] < 32 || s[j] > 126) {
found = false;
break;
}
}
}
if (found) {
cout << "seed:" << seed << ", string:" << s;
}
else
cout << "NOT FOUND";
return 0;
}
…안된다… 뭔가를 잘못했나 싶어서 라이트업도 참고 해봤는데 라이트업 코드도 실행이 안된다 왜일까…?
라우터: data 전달하는 경로(route)를 정하는 장치
이중에서 (가정용) 공유기는 단일 경로만 다루고 있다.
스위치는 data의 통로를 선별하여 차단 또는 개방하는 문지기 역할을 수행 -> 불필요 data 필터링
라우터 기능
IP주소 사용하여 전달, 두 프로토콜이 같아야 함
- 경로 설정 -> 길을 검사하고 테이블링
- 패킷 전달
스위치, 라우터 차이점…
스위치: 중간에 있어서 경로를 스위칭 -> 데이터 링크 계층
라우터: 경로를 찾아주는 -> 네트워크 계층
라우터는 관리자의 설정으로 라우팅 테이블 생성, 통신 필요
경로 설정에는 인위적으로 등록하는 정적 경로(Static Routing)과 알고리즘에 의해 판단하는 동적 경로(Dynamic Routing)가 있다.
- 정적 경로 설정 (직접 지정)
- 동적 경로 설정 (알고리즘)
스위칭할 때
동일한 그룹 내 정보 교환은 IGP 프로토콜로…
다른 그룹 사이 정보 교환은 EGP 프로토콜로…

target: TP-link M7350 Travel WiFi Router
휴대용 라우터: 일반 라우터 + 무선 어댑터 역할
4G 대상인 거 같다
이더넷 케이블 연결 -> 라우터를 액세스 포인트로 활성화 -> 기기에서 암호로 로그인 -> 라우터 사용
ISP와 포켓 라우터 연결 -> 무선 신호 브로드캐스팅
v3의 제품설명서이지만… 일단 작동 방식은…
IP address는 192.168.0.1이고, 서브넷 마스크는 255.255.255.0 -> 변경 가능
Connect to the Internet
- 무선 네트워크 연결
- SSID 선택한 다음 연결 -> menu>Device Info에 SSID, PW, Login Name 담겨 있음

- 해당 화면 확인해서 기기에서 PW 입력하면 연결됨
휴대폰이나 패드는 http://192.168.0.1 입력하고 아이디, 비밀번호 admin으로 로그인하면 좀 더 빠른 설정
웹 기반 관리 페이지에는 다음과 같은 기능 존재

간단하게 살펴 봤는데
- Wizard
- Status: 읽기 전용이라 딱히..
- SMS: 받은, 보낼 편지 등 메세지 담겨 있음
- advanced
- AES
공개키, 대칭 알고리즘
128,192,256 비트 세 가지 종류가 AES 표준
알고리즘
- 평문을 128비트 또는 16바이트 블록화
- 열 우선 행렬 생성(state matrix)
- KeyExpansion: 암호화 키 가져와서 추가키 생성, 각 라운드마다 key 하나 생성 -> round key
- AddRoundKey: 라운트 키의 각 바이트 XOR 상태 행렬의 바이트,
( x_{i,j} = p_{i,j} \oplus k_{i,j} )
- 일련의 라운드 수행: SubBytes -> ShiftRows -> MixColumns(마지막 라운드 제외) -> AddRoundKey
#예제에서 쓰인 블록 암호 모드는 ECB, 해당 부분 설명으로 아래서...
import string
import random
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
#암호화
def aes_encrypt(key, plaintext):
cipher = AES.new(key, AES.MODE_ECB)
ciphertext = cipher.encrypt(pad(plaintext.encode('utf-8'), AES.block_size)) #AES 사이즈 지정
return ciphertext
#복호화
def aes_decrypt(key, ciphertext):
cipher = AES.new(key, AES.MODE_ECB) #이때 key는 대칭키
decrypted_data = unpad(cipher.decrypt(ciphertext), AES.block_size)
return decrypted_data.decode('utf-8')
- DES
비밀키, 대칭 암호 알고리즘
알고리즘
- Initial permutation: 각 비트 치환
- 라운드 함수로 서로 다른 서브키로 각 비트 치환
- 블록화
- Final permutation: 한 번 더 치환
from Crypto.Cipher import DES
from Crypto.Hash import SHA256 as SHA
class myDES():
# DES 초기화
def __init__(self, keytext, ivtext):
hash = SHA.new()
hash.update(keytext.encode('utf-8'))
# keytext를 해시화했을 때 첫 8byte를 key로 함
key = hash.digest()
self.key = key[:8]
hash.update(ivtext.encode('utf-8'))
iv = hash.digest()
# ivtext를 해시화했을때 첫 8byte를 iv로 함
# iv는 CBC 모드 운영을 위한 초기화벡터를 말함
self.iv = iv[:8]
# ECB 모드로 암호화
def encrypt_ECB(self, plaintext):
# 항상 8byte 단위로 끊어서 암호화하기 때문에 평문이 8byte로 끊기지 않는다면
# padding값을 추가해 8byte로 만들어줌
while(len(plaintext) % 8 != 0):
plaintext += ' '
des = DES.new(self.key, DES.MODE_ECB)
encryptMsg = des.encrypt(plaintext.encode())
return encryptMsg
# ECB 모드로 암호화된 암호문을 복호화
def decrypt_ECB(self, ciphertext):
des = DES.new(self.key, DES.MODE_ECB)
descryptMsg = des.decrypt(ciphertext)
return descryptMsg
# CBC 모드로 암호화
def encrypt_CBC(self, plaintext):
while(len(plaintext) % 8 != 0):
plaintext += ' '
# CBC 모드에서는 iv(초기화 벡터) 값이 필요
des = DES.new(self.key, DES.MODE_CBC, self.iv)
encryptMsg = des.encrypt(plaintext.encode())
return encryptMsg
# CBC 모드로 암호화된 암호문을 복호화
def decrypt_CBC(self, ciphertext):
des = DES.new(self.key, DES.MODE_CBC, self.iv)
descryptMsg = des.decrypt(ciphertext)
return descryptMsg
- RSA
비대칭 암호 알고리즘
공개키, 비밀키 모두 사용하는데, 공개키는 암호화할 때, 비밀키는 복호화할 때 사용
알고리즘
- 두 큰 소수를 곱해서 공개키 생성
- 이때 개인키는 (공개키X개인키)mod 오일러(p*q)=1인 숫자 -> ( (e * d) \mod \Phi(n) = 1 )
( n = p * q ) (p, q는 소수)
( \Phi(n) = (p — 1) * (q — 1) )
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
def generate_keys():
key = RSA.generate(2048)
# 개인키 생성
private_key = key.export_key()
# 공개키 생성, 이때 서로 소수
public_key = key.publickey().export_key()
return private_key, public_key
# 암호화
def encrypt_message(public_key, message):
rsa_key = RSA.import_key(public_key)
# OAEP는 RSA와 함께 사용되는 Padding
cipher = PKCS1_OAEP.new(rsa_key)
encrypted_message = cipher.encrypt(message.encode())
return encrypted_message
# 복호화
def decrypt_message(private_key, encrypted_message):
# key import(개인키)
rsa_key = RSA.import_key(private_key)
cipher = PKCS1_OAEP.new(rsa_key)
decrypted_message = cipher.decrypt(encrypted_message).decode()
return decrypted_message
- 해시 함수
해시 함수들은 복호화되지 않는다. 한 번 압축함수를 거치면 되돌릴 수 없다(예측 불가능성)
해시 암/복호화 툴 사이트 (CTF에서 필요할 때 들어가기..)
4-1. SHA256
SHA 해시 함수는 1, 256, 384 등.. 다양함
어떤 입력값이든 고정된 길이 n(SHA256에서는 256)으로 변환 처리한다.
알고리즘
1) 패딩 -> 512bit의 배수가 되도록
2) 파싱 -> 32bit씩 나눈다.
3) 해싱
import hashlib #이전 암호 라이브러리와 다르다
# SHA-256 해시 객체 생성
hash_object = hashlib.sha256()
# 데이터 업데이트
hash_object.update(data.encode())
# 해시 값 추출
hash_value = hash_object.hexdigest()
4-2. MD5
128비트 길이를 만들어주는 해시 함수
SHA1보다는 안전하나 지금은 원본을 찾을 수 있는 빠른 알고리즘이 나와 잘 사용하진 않는다.
알고리즘
- 데이터 비트 오른쪽에 1
- 비트 길이 448(mod 512)되도록 0 패딩(오른쪽에)
- 마지막 64비트에 padding되기 전 데이터 길이 저장(리틀 엔디언)
- 512비트 블록으로 쪼개기
- 각 블록에 대해 연산
import hashlib
result = hashlib.md5(data.encode()).hexdigest()
- 블록 암호
평문을 분할하여 m길이*n개 블록을 만드는 알고리즘이다.
운영모드는 5가지가 있는데 ECB, CBC, CFB, OFB, CTR
- 5-1. ECB
- 블록 단위로 나누고 각 블록마다 key로 암호화
반복 공격에 취약하다.

- 5-2. CBC
- iv로 첫 블록을 암호화하고 다음 블록은 이전 암호와 평문을 xor 후 key로 암호화
초기 iv가 동일하며 출력 결과가 동일한 취약점이 있다.

- 5-3. CFB
- iv로 첫 블록 암호화하고 이후 블록은 key와 암호화 후 평문과 xor
재전송 공격에 취약하다.

- 5-4. OFB
- CFB와 이전 블록의 결과를 가져다 쓰는 위치가 다르다(그림으로 확인하기)
OFB는 xor 전 암호화 결과를 다음 블록에서 사용한다.

- 5-5. CTR
- 암호화 할 때마다 증가하는 Counter 변수를 두고, 이것을 key와 암호화

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
KEY = ?
FLAG = ?
@chal.route('/ecb_oracle/encrypt/<plaintext>/')
def encrypt(plaintext):
plaintext = bytes.fromhex(plaintext)
padded = pad(plaintext + FLAG.encode(), 16)
cipher = AES.new(KEY, AES.MODE_ECB)
try:
encrypted = cipher.encrypt(padded)
except ValueError as e:
return {"error": str(e)}
return {"ciphertext": encrypted.hex()}
flag도 key도 모름… plain은 넣을 수 있음
16바이트로 padding
음… 문제 감을 못잡겠어서 라이트업 참고했다…
ECB Oracle
해당 사이트에서 푸는 거였다
패딩은 16 단위이다. 왼쪽은 \x61을 15번 채우면 flag에서 한 글자를 가져올 수 있다.
오른쪽에서 브루트포스처럼 a부터 넣어서 같은 output을 출력하는 한 글자를 찾으면 그 값이 flag의 첫글자이다.
이후에는 \x61 14번 + 찾은 글자 + 찾을 flag 한 글자로 반복한다.
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import time
import requests
#ciphertext를 받아온다.
def get_cipher(plaintext):
chellenge_site = 'https://aes.cryptohack.org/ecb_oracle/encrypt/' + plaintext.hex() + '/'
r = requests.get(chellenge_site)
ciphertext = r.json()
return bytes.fromhex(ciphertext['ciphertext'])
flag=b''
#range 범위 설정에 대해서는 후술...
for i in range(0, 32):
plaintext = b'\x61' * (31 - i) #\x61을 31-i만큼 채운다
ciphertext = get_cipher(plaintext)[:32] #보내고 ascii 하나씩 대입해서 비교
plaintext += flag #찾아둔 flag들 더해야 함
print("try %d" % (i+1))
#ascii 브루트포스 for문
for j in range(33, 127):
plaintext = plaintext[:31]
plaintext += j.to_bytes(1, byteorder='big')
wr_ci = get_cipher(plaintext)[:32]
#같다면
if (wr_ci == ciphertext):
flag += j.to_bytes(1, byteorder='big')
print(flag)
break
time.sleep(1)
길이는 어떻게 알았냐 하면…
61을 1번 넣고, 2번 넣고… 하다보면 7번의 61을 넣었을 떄 갑자기 cipher가 길어진다. -> 블록이 추가됐다는 뜻
7+x=32
x=25이다.