목요일에 사용자 수가 기하급수적으로 늘어났다. 알고보니 이세계 페스티벌(?) 이라는 버츄어 아이돌 관련 페스티벌 티켓 오픈일이 저녁 8시 였더라..
사용자가 많아진 만큼 이상한 사람들도 많았다. 채팅을 도배로 가득 채우거나 닉네임을 말도안되게 길게 설정하는 사용자 등이 있었다. 애초에 검증 로직을 엄격하게 설정하지 않은 내 잘못이 크긴하다 ㅜㅜ
저녁에 퇴근하고 채팅 관리도 할겸 데이터 마이그레이션을 진행했다. Redis로는 관리하기가 조금 까다로워서 Mongo로 마이그레이션을 했다. 로컬환경에서 이미 여러번 테스트 했기 때문에 바로 스크립트를 실행했다.
당연히 정상 실행되었고 기능적으로 잘 동작하는지 여부까지 확인했다. 뿐만아니라 블로그 글까지 정리하고 난 뒤 잠에들었다. 그런 후 아침에 눈을 뜨고 사이트 채팅창을 먼저 확인했다. 근데 왠걸? 채팅창이 초기화 되어버렸다. "이게 무슨일이지?" 하며 실서버 MongoDB에 접속했다. 분명 어제까지 존재했던 데이터가 싸그리 없어져버렸다.
몽고DB 로그를 까보니 수상한 흔적이 보였다. 채팅 데이터 관련 컬렉션을 drop하고 "READ_ME_TO_RECOVER_YOUR_DATA" 라는 데이터베이스를 생성한 것이 확인됐다.
"이게 뭐지..?" 하며 확인했는데 안에 담긴 데이터는 다음과 같았다. 대충 요약하자면 너의 데이터는 내가 백업해두었으니 이걸 찾으려면 0.0045BTC를 내놓으라는 소리였다. 0.0045BTC가 아니라 0.0045원이더라도 줄 생각은 없었지만 대략 액수가 얼마인지는 궁금해졌다. 현재 가격 기준으로 116,912,820 * 0.0045 = 526,108 대략 50만원 가까운 금액이었다. 데이터 양이 많지 않아서 그런가 생각보다 엄청 비싸진 않았다. 반드시 복구해야되는 중요 데이터였다면, 어후 생각만해도 아찔하다..
암튼 털린건 털린거고 원인을 알아야했다. 어느 곳을 가서 작업을 해도 실서버 DB로 접근이 가능하게 모든 Ip에 대한 접근을 열어두긴 했다. 근데 비밀번호 설정은 당연히 해두었기 때문에 이렇게 쉽게 털리는게 말이안된다고 생각했다.
좀 알아보니 MongoDB같은 경우 기본 설정 값으로 security.authorization 설정 값이 비활성화 상태라는 걸 알게되었다. 저 설정값이 비활성화 된다면, 계정에 비밀번호를 설정하더라도 비밀번호 없이 DB 접근이 가능해진다. 참 희한한 DB다.. MySQL 처럼 당연히 기본으로 인증 과정은 거치는줄 알았는데 내 착각이었다.
바로 /etc/mongd.conf에서 다음과 같이 인증 과정을 활성화해주었다. 설정하기 전까지는 아무 곳에서나 admin 계정으로 정말 비밀번호 없이 접근이 되더라.. 설정해주니까 드디어 접근이 제한되었다.
근데 이걸로는 조금 부족했다. 아무리 비밀번호를 복잡하게 설정해도 무차별 대입을 통해서 뚫릴 가능성은 존재하기 떄문이다. 먼저 ISP 서비스를 통해 MongoDB 포트포워딩 외부포트를 변경했다. 랜덤하게 설정해서 기본포트 27017을 통한 접근을 최소화하려고 한다.
다음 보안조치는 Fail2Ban이다. fail2ban은 서버에 대한 무차별 대입(brute force) 공격을 방어하기 위해 널리 사용되는 로그 기반 침입 방지 시스템이다. 이를 활용하면 일정 횟수 이상 실패가 반복되면 특정 ip에서 접근을 못하도록 설정할 수 있다.
fail2ban의 필터 설정을 다음과 같이 해주었다. failregex에 해당하는 로그 형식이 포착되면 실패 카운트를 한다.
[Definition]
failregex = \{"t":\{"\$date":".*?"\},.*?"msg":"Authentication failed".*?"remote":"<HOST>:[0-9]+"
ignoreregex =
[Init]
maxlines = 1
그리고 jail.d의 mongo-conf에는 다음과 같이 실패 횟수에 따른 ban 설정을 명시해줬다. 간단하게 설명하자면 600초(10분) 안에 3번 인증에 실패하면 bantime -1 (무기한) 으로 ip접근을 제한시킨다.
[mongodb-auth]
enabled = true
filter = mongodb-auth
port = 27017
logpath = /var/log/mongodb/mongod.log
maxretry = 3
findtime = 600
bantime = -1
이렇게 설정하고 실제 Remote 환경에서 3번 인증 실패를 하게되면 다음과 같이 접근이 차단된다. 총 3번 실패 이후 connect ECONNREFUSED 메세지가 뜬걸 확인할 수 있다. 즉 인증 시도 없이 바로 fail2ban을 통해 ip접근이 차단된거다.
서버에서 banIp 목록을 조회하면 다음과 같이 나온다. 118.235.11.91 이 remote 환경에서 접근을 시도한 IP이다.
포트포워딩으로 접근 수단을 은닉하고 Fail2ban 설정을 통해 무차별 공격도 예방했다. MySQL이었으면 계정마다 접근 권한을 다르게 하여
관리자 계정은 로컬에서만 접근, 이외 계정은 타 Ip에서 접근하도록 설정이 가능하겠지만 MongoDB의 경우 그러한 설정이 불가하다 ㅜ
그래서 부가적으로 위와 같은 보안 수단을 도입했다.
보안 조치는 이쯤하면 된 것 같고 이제 복구를 해야된다. 이미 날라간 데이터를 복구하는건 불가능하다.. 백업을 해두지도 않았고 MongoDB Atlas를 사용하지도 않았기 떄문에 (스냅샷을 제공해준다고 한다) 복구할 방법이 전혀 없었는데, 전날 켜둔 사이트 네트워크 창에서 희망을 발견했다.
다행히 데이터가 날라간 시점에는 무한스크롤 페이징 처리가 구현되어 있지 않아서, 모든 채팅 내역을 한번에 받아와 프론트 처리를 해줬다. 그래서 응답 데이터에 전체 채팅 데이터가 담겨있었다.
더군다나 Json 형식이었기 떄문에 정말 손쉽게 Node 스크립트를 활용해서 순식간에 모든 데이터를 복구 할 수 있었다. 아래는 복구할 때 작성한 노드 스크립트이다.
import fs from 'fs';
import mongoose from 'mongoose';
mongoose.connect("mongodb://주소", {
useNewUrlParser: true,
useUnifiedTopology: true,
authSource: "admin",
})
.then(() => console.log("MongoDB 연결 완료"))
.catch((err) => console.error("MongoDB 연결 실패:", err));
const chatSchema = new mongoose.Schema({
name: String,
text: String,
key: String,
createdAt: Date
}, {
versionKey: false
});
const Chat = mongoose.model("Chat", chatSchema, "chat");
const existing = await Chat.find({}, { name: 1, text: 1, key: 1, createdAt: 1 });
const existingKeys = new Set(
existing.map(doc =>
`${doc.name}|${doc.text}|${doc.key}|${doc.createdAt.toISOString()}`
)
);
try {
const data = fs.readFileSync("chat.json", "utf-8");
let parsed_data = JSON.parse(data);
parsed_data = parsed_data
.map(item => ({
name: item.name,
text: item.text,
key: item.key,
createdAt: new Date(item.send_at)
}))
.filter(item => {
const uniqueKey = `${item.name}|${item.text}|${item.key}|${item.createdAt.toISOString()}`;
return !existingKeys.has(uniqueKey);
});
// parsed_data = parsed_data.slice(0, 10);
await Chat.insertMany(parsed_data);
await mongoose.disconnect();
} catch (e) {
console.error("오류 발생:", e);
}
로컬에서 정상적으로 복구된 걸 확인한 다음 실서버에 반영하기 이전에 부족한 검증 로직과 무한스크롤 페이징 기능을 도입했다. 무한스크롤 처리가 생각보다 까다로웠다. 보통 채팅의 경우 채팅박스 요소 맨 마지막에 새로 생성된 채팅이 쌓이게 되는데, 무한 스크롤의 경우 맨 위에 데이터를 차례대로 쌓아야한다. 또한 날짜 라벨이나 닉네임이 동일할 경우 닉네임이 한번만 위치할 수 있도록 하는 처리도 역순으로 해주어야 하는데 많이 해맸던 것 같다.
이런 저런 시도 끝에 구현할 수 있었고 다음과 같이 무한스크롤이 적용된 채팅 기능을 실서버에 반영할 수 있었다.
보안 관련해서 조금 안일하게 생각했었는게 결국 이런 사단이 났다. 결과론적으론 좋은 경험을 한 것 같다. 앞으로도 보안 부분을 소홀하게 생각지말고, 더 각별한 신경을 써야겠다.
'Undefined' 카테고리의 다른 글
홈서버 메모리 아끼기 (0) | 2025.04.10 |
---|---|
쿠폰 발급 프로젝트 (2) (3) | 2023.12.02 |
쿠폰 발급 프로젝트 (1) (0) | 2023.11.12 |
학습의 3단계 / 시스템의 중요성 (0) | 2023.11.03 |
개발 입문 1주년 회고 - (1) (0) | 2023.07.07 |