JS/TIL(Today I Learned)

2025-03-11 <최종 프로젝트 D-3> EC2 서버 Brute Force 공격 대응하기

프린스 알리 2025. 3. 11. 19:25

🤬 TROUBLE : 사건의 발단

🌊 침입 발생

EC2 서버를 본격적으로 열어두기 시작한 이래로, 서버가 멈춰버리는 현상이 자주 발생했다.

처음엔 프로젝트의 코드가 가진 결함을 의심했으나, 로그를 뜯어보는 중 수상한 행적을 발견할 수 있었다.

 

 

아침 7시에 열어둔 서버에 누군가 접속한 모습이다.

45.33.80.243이라는 IP가 접속한 이후, 서버는 완전히 멈춰버렸다.


🤔 현상 관찰

즉시 AWS를 통해 모니터링 결과를 살펴봤다.

 

 

대체 들어와서 뭘 한 건지, CPU 사용률이 50%에 가까운 모습이다.

JMeter 부하 테스트 결과와 비교해봤더니, 300개의 클라이언트가 동시에 패킷을 3번씩 보냈을 때와 CPU 사용량이 맞먹는 수준이다.

 

 

참고로 평소 서버의 그래프는 위와 같다.

애플리케이션 실행 초기에 CPU 사용량이 최대치를 찍고 우하향을 그리는 게 일반적인 모습이다.


🐱‍👤 범죄 색출

느낌이 왔다.

언젠가 2조가 원격서버에 공격이 들어온 적이 있다고 이야기를 했기에 곧바로 인터넷에 해당 IP를 검색했다.

 

 

아니나 다를까, IP와 포트에 대해 무차별 공격을 하는 어뷰저였음을 알 수 있었다.

이대로 좌시한다면 게임 서버가 위태로운 상황.

튜터님께 문제에 대해 조언을 구했고 AWS에서 제공하는 서비스를 검색하며 해결책을 강구하게 되었다.


✨ SOLVE 1 : 블랙 리스트 구현하기

튜터님께서 말씀하시길, 디도스 공격을 비롯한 무작위 공격은 근본적인 해결이 어렵다 하셨다.

그렇기에 예방책을 세우고 감지 체계를 만드는 게 도움이 될 것이라고도 덧붙이셨다.

따라서 우리는 어뷰저의 IP를 블랙리스트에 저장하여 관리하기로 결정했다.

그러기 위해선, 기존의 onConnection 함수에서 onData를 호출하기 전에 블랙리스트 등록 여부를 검사하는 게 우선이었다.

 

export const onConnection = async (socket) => {
  const clientIP = socket.remoteAddress; // 헬스 체크 및 클라이언트 IP 확인
  console.log('클라이언트가 연결되었습니다:', clientIP, socket.remotePort);

  if (clientIP.startsWith('172.31.')) {
    console.log('헬스 체크 요청 감지, 세션 종료');
    socket.destroy(); // 헬스 체크 요청이면 즉시 종료
    return;
  }

  const blacklist = await getBlacklist();

  if (blacklist.has(clientIP)) {
    console.log('잡았다, 요놈!', clientIP);
    socket.destroy(); // 블랙리스트에 올라간 어뷰저의 IP면 즉시 종료
    return;
  }

  // 소켓 객체에 buffer 속성을 추가하여 각 클라이언트에 고유한 버퍼를 유지
  socket.buffer = Buffer.alloc(0);
  socket.id = uuidv4();

  // 유저 생성
  const userSession = getUserSessions();
  const newUser = userSession.setUser(socket);

  socket.on('data', onData(socket));
  socket.on('end', onEnd(socket));
  socket.on('error', onError(socket));
};

 

이미 연결된 클라이언트가 도중에 차단될 경우를 고려하여, onData에서도 다시 한 번 차단하기로 했다.

 

export const onData = (socket) => {
  if (!socket.buffer) {
    console.log('socket.buffer is undefined of null');
    return;
  }

  socket.anomalyCounter = 0;
  socket.requestCounter = 0;

  setInterval(() => {
    socket.requestCounter = 0;
  }, 1000);

  return async (data) => {
    if (!data) {
      console.log('Data is undefined or null');
      return;
    }

    const clientIP = socket.remoteAddress;

    // 블랙리스트 IP인지 확인 후 차단
    if (await isBlacklisted(clientIP)) {
      console.log(`차단된 IP(${clientIP})의 접근 시도 감지 -> 연결 종료`);
      socket.destroy();
      return;
    }

		// 패킷 크기 검사
    if (data.length > config.blacklist.MAX_PACKET_SIZE) {
      console.log(
        `너무 큰 패킷 감지 (크기: ${data.length}, IP: ${clientIP}) -> 연결 종료`,
      );
      await addBlacklist(clientIP);
      socket.destroy();
      return;
    }


		// 생략

	}
}

 

블랙리스트 - 레디스에서 관리

처음엔 서버에서 new Set()을 선언하여 블랙리스트를 관리하는 쪽으로 코드를 짰는데,

블랙리스트는 지속적으로 가지고 있어야 할 데이터이기에 DB에 저장하기로 방향을 수정하였다.

패킷을 읽고 쓰는 함수 내부에서 사용될 예정이기에, MySQL보단 Redis를 이용하는 게 적합할 것이다.

 

import redisClient from '../utils/redis/redis.config.js';
import chalk from 'chalk';

export const addBlacklist = async (IP) => {
  try {
    // Redis에 블랙리스트 추가 (Set 자료형 사용)
    await redisClient.sadd('Blacklist:abuserIPs', IP);
    console.log(
      chalk.green(`[Redis Log] ${IP}가 블랙리스트에 추가되었습니다.`),
    );
  } catch (error) {
    console.error(
      chalk.red(`[Redis Error] 블랙리스트 추가 실패: ${error.message}`),
    );
  }
};

export const getBlacklist = async () => {
  try {
    // Redis에서 모든 블랙리스트 IP 조회
    const blacklistedIPs = await redisClient.smembers('Blacklist:abuserIPs');
    return new Set(blacklistedIPs);
  } catch (error) {
    console.error(
      chalk.red(`[Redis Error] 블랙리스트 조회 실패: ${error.message}`),
    );
    return new Set();
  }
};

export const isBlacklisted = async (IP) => {
  try {
    // 해당 IP가 블랙리스트에 포함되어 있는지 확인
    const isMember = await redisClient.sismember('Blacklist:abuserIPs', IP);
    return isMember === 1;
  } catch (error) {
    console.error(
      chalk.red(`[Redis Error] 블랙리스트 확인 실패: ${error.message}`),
    );
    return false;
  }
};

 

차단 기준 1 - 너무 빈번한 요청이 발생할 때

초당 요청 제한을 70으로 정했다.

 

// 초당 요청 제한
socket.requestCounter++;
if (socket.requestCounter > config.blacklist.MAX_REQUESTS_PER_SECOND) {
	console.log(`과도한 요청 감지 (요청 횟수 : ${socket.requestCounter}, IP: ${clientIP}) -> 용의자 리스트 추가`);
	await addSuspect(socket.remoteAddress);
	socket.buffer = Buffer.alloc(0); // 버퍼 초기화
}

 

처음엔 MAX_REQUESTS_PER_SECOND를 20으로 설정했으나, 게임 내에서 도구를 바꿀 때마다 패킷이 전송되기 때문에(파티원 동기화 때문에 단기간엔 고칠 수 없는 상황) 블랙리스트에 바로 올리는 대신 용의자 리스트에 추가하여 추후에 로그를 확인할 수 있게끔 작성했다.

 

 

차단 기준 2 - 잘못된 패킷이 감지됐을 때

while (socket.buffer.length >= headerSize) {
	const packetSize = socket.buffer.readUInt32LE(0);

	if (
		!packetSize ||
		packetSize < headerSize ||
		packetSize > config.blacklist.MAX_PACKET_SIZE
	) {
		console.log(
			`잘못된 패킷 크기: ${packetSize} (IP: ${socket.remoteAddress})`,
		);
		socket.buffer = Buffer.alloc(0); // 버퍼 초기화
		socket.anomalyCounter += 1;
		if (socket.anomalyCounter >= 5) {
			await addBlacklist(socket.remoteAddress);
			socket.destroy(); // 악의적인 패킷이므로 소켓 종료
		}
		return;
	}

	const packetId = socket.buffer.readUInt8(config.packet.totalSize);
	if (!packetIdEntries.some(([, id]) => id === packetId)) {
		console.log(
			`잘못된 패킷 ID: ${packetId} (IP: ${socket.remoteAddress})`,
		);
		socket.anomalyCounter += 1;
		if (socket.anomalyCounter >= 5) {
			console.log(`반복적인 잘못된 패킷 ID 감지 (IP: ${clientIP}) -> 차단`);
			await addBlacklist(clientIP);
			socket.destroy();
		}
		return;
	}

	// 생략

}

 

const decodedPacket = (socket, packetType, packetDataBuffer) => {
  try {
    return getProtoMessages()[packetType].decode(packetDataBuffer);
  } catch (error) {
    console.log(`패킷 디코딩 실패 (IP: ${socket.remoteAddress})`);
    socket.buffer = Buffer.alloc(0); // 버퍼 초기화
    socket.anomalyCounter += 1;

    if (socket.anomalyCounter >= 5) {
      addBlacklist(socket.remoteAddress).then(() => {
        socket.destroy(); // 악의적인 패킷이므로 소켓 종료
      });
    }

    return null;
  }
};

 

이 경우엔 바로 블랙리스트에 올리진 않고, 소켓의 anomalyCounter를 증가시켜서 5를 넘는 순간 블랙리스트에 등록하고 소켓 연결을 강제로 끊어버린다.

차단 기준 3 - 너무 큰 패킷을 전송할 경우

어김 없이 등장하신 우리의 어뷰저님.

 

 

잘못된 패킷 ID: 47 (IP: 20.14.80.89)
연결 췤!:  3003
연결 췤!:  6003
연결 췤!:  9002
클라이언트 연결이 종료되었습니다. (END)

 

존재하지 않는 패킷 ID로 공격을 시도한 모양이다.

 

const packetId = socket.buffer.readUInt8(config.packet.totalSize);
if (!packetIdEntries.some(([, id]) => id === packetId)) {
	console.log(
		`잘못된 패킷 ID: ${packetId} (IP: ${socket.remoteAddress})`,
	);
	socket.anomalyCounter += 1;
	if (socket.anomalyCounter >= 5) {
		console.log(`반복적인 잘못된 패킷 ID 감지 (IP: ${clientIP}) -> 차단`);
		await addBlacklist(clientIP);
		socket.destroy();
	}
	return;
}

 

다만 "반복적인 잘못된 패킷 ID 감지"가 출력되지 않는 걸로 보아 패킷 하나로 서버를 다운시킨 듯한데…. 아주 큰 패킷 하나를 보낸 게 아닌가 싶다.

이런 경우를 완벽히 배제하기 위해 예외 처리 코드와 버퍼 초기화 코드를 추가했다.

 

// 패킷 크기 검사
if (data.length > config.blacklist.MAX_PACKET_SIZE) {
  console.log(
    `너무 큰 패킷 감지 (크기: ${data.length}, IP: ${clientIP}) -> 연결 종료`,
  );
  await addBlacklist(clientIP);
  socket.destroy();
  return;
}

// 생략...

const packetId = socket.buffer.readUInt8(config.packet.totalSize);
if (!packetIdEntries.some(([, id]) => id === packetId)) {
  console.log(
    `잘못된 패킷 ID: ${packetId} (IP: ${socket.remoteAddress})`,
  );
  socket.anomalyCounter += 1;
  socket.buffer = Buffer.alloc(0); // 버퍼 초기화
  if (socket.anomalyCounter >= 5) {
    console.log(`반복적인 잘못된 패킷 ID 감지 (IP: ${clientIP}) -> 차단`);
    await addBlacklist(clientIP);
    socket.destroy();
  }
  return;
}

 

✨ SOLVE 2 : AWS VPC- 네트워크 ACL에서 IP 차단하기

블랙리스트를 만들었으면 이 다음 단계는 접속을 원천 차단하는 것이다.

AWS가 제공하는 인프라(EIP, NLB, 오토 스케일링)를 이용하고 있으므로, 네트워크 ACL 콘솔을 통해 해결해 보았다.

일단 EIP와 NLB에서 공통으로 사용 중인 VPC 콘솔로 들어갔다.

블랙리스트 데이터를 기반으로 인바운드 규칙에 특정 IP로부터의 접근을 거부해두었다.