메모리복사시 침범 문제 - 버퍼 오버플로의 원인을 바로잡다
boost asio를 이용하여 코드를 작성 중에 다음과 같은 부분이 있었다.
클래스의 멤버변수로 네트워크 처리에 사용할 버퍼를 만들고 변수를 생성한 코드다.
class BasicSocket : public std::enable_shared_from_this<BasicSocket>
{
// 중략
protected:
std::shared_ptr<Socket> socket_ptr_;
Socket socket_;
char packet_buffer_[RECV_BUFFER_SIZE * 2];
int32_t remain_size_;
char recv_buffer_[RECV_BUFFER_SIZE];
char send_buffer_[SEND_BUFFER_SIZE];
};
이후 이 변수들을 사용해서 다음과 같은 코드를 작성했다.
std::cout << "OnReceive 1 remain_size_:" << remain_size_ << " - bytes_transferred:" << bytes_transferred << std::endl;
// 패킷을 담아둘 버퍼에 수신버퍼의 내용을 복사한다.
memcpy(&packet_buffer_[remain_size_], recv_buffer_, bytes_transferred);
std::cout << "OnReceive 2 remain_size_:" << remain_size_ << " - bytes_transferred:" << bytes_transferred << std::endl;
데이터 수신 후 처리하는 부분인데 단순히 packet_buffer에 memcpy를 하기만했는데 remain_size의 값이 변화한다는 사실.
위에서 출력된 remain_size와 밑에서 출력된 remain_size의 값이 다르다는 것이었다. 더 문제는 이 출력이 어떤 경우에는 정상적이고 어떤 경우에는 다르게 출력되었다.
remain_size가 할당된 위치가 packet_buffer의 바로 다음 메모리 공간이었다. memcpy가 packet_buffer의 경계를 넘어 remain_size의 메모리까지 덮어썼다는 것을 VS2015의 메모리 디버거로 확인했다.
2026년 수정 안내 이 글의 원래 결론은 두 군데가 틀렸다. 원인을 “recv_buffer와 packet_buffer의 크기가 같기 때문”이라고 적었는데, 선언을 보면
packet_buffer_는RECV_BUFFER_SIZE * 2로recv_buffer_보다 두 배 크다. 그리고 마지막에 “프로그램마다 메모리 배치가 달라서 그런가?”라고 맺었는데, 이건 버퍼 오버플로를 우연의 문제로 돌린 잘못된 결론이다. 진짜 원인과 함께 바로잡는다.
진짜 원인 : 경계 검사가 없는 memcpy
memcpy(&packet_buffer_[remain_size_], recv_buffer_, bytes_transferred);에는 쓰기 전에 목적지가 충분히 남아 있는지 확인하는 코드가 없다.
packet_buffer_의 전체 크기는 RECV_BUFFER_SIZE * 2다. remain_size_만큼 이미 채워진 상태에서 bytes_transferred만큼 더 쓰려는 것이므로, 다음 조건이 성립해야 안전하다.
remain_size_ + bytes_transferred <= sizeof(packet_buffer_)
이 검사가 없으니, remain_size_가 충분히 크고 bytes_transferred도 큰 상황이 겹치면 packet_buffer_의 끝을 넘어 그 바로 뒤에 선언된 remain_size_ 자신의 메모리까지 덮어쓴다. remain_size_가 memcpy 호출 한 번으로 바뀌어 보였던 이유가 이것이다. 클래스 멤버는 선언 순서대로 배치되는 경우가 일반적이라, packet_buffer_ 바로 다음에 오는 remain_size_가 흔한 피해자가 된다.
이건 명백한 버퍼 오버플로이고 정의되지 않은 동작(UB)이다. 두 값의 크기가 같은지 다른지는 원인이 아니다 — 애초에 경계를 확인하지 않았다는 것이 원인이다.
“다른 서버는 에러가 안 났다”의 진짜 의미
원래 이 글은 “같은 코드인데 다른 서버에서는 에러가 안 났다. 프로그램마다 메모리 배치가 다른가 보다”로 맺었는데, 이 결론이 위험하다. UB는 겉으로 멀쩡해 보이는 것과 실제로 안전한 것이 다르다.
다른 서버에서 증상이 안 보인 것은 메모리 배치가 우연히 달랐거나, 침범한 뒤에 있는 값이 그 순간 안 쓰이는 값이었거나, 침범 범위가 우연히 패딩 안에 들어갔거나 하는 식의 우연일 뿐이다. 오버플로 자체는 똑같이 일어나고 있었을 가능성이 크다. 컴파일러 버전, 최적화 옵션, 디버그/릴리즈 빌드 하나만 바뀌어도 증상이 나타나거나 사라질 수 있다.
수정
경계 검사를 추가하고, 넘치는 경우를 명시적으로 처리한다.
void BasicSocket::OnReceive( std::size_t bytes_transferred )
{
if ( remain_size_ + bytes_transferred > sizeof( packet_buffer_ ) )
{
// 비정상적으로 큰 패킷이거나 파싱이 밀린 상태. 연결을 끊거나 로그를 남긴다.
HandleBufferOverflow();
return;
}
memcpy( &packet_buffer_[ remain_size_ ], recv_buffer_, bytes_transferred );
remain_size_ += static_cast<int32_t>( bytes_transferred );
}
재발을 막으려면
-
AddressSanitizer(ASan)로 빌드해서 테스트한다. 이런 종류의 버그는 증상이 간헐적이라 로그만으로 찾기 매우 어렵다. ASan은 경계를 넘는 순간 그 자리에서 바로 크래시와 스택 트레이스를 보여준다.
# GCC/Clang g++ -fsanitize=address -g main.cppMSVC도 2019 16.9 이상부터
/fsanitize=address를 지원한다. - 버퍼를 직접 다루는 대신
std::vector나 링 버퍼 클래스로 감싼다. 크기를 넘는 삽입을 시도하면 예외를 던지거나 자동으로 확장하도록 만들면, 애초에 이런 실수가 나기 어려운 구조가 된다. - 네트워크 버퍼처럼 신뢰할 수 없는 크기가 들어오는 지점은 항상 경계 검사를 습관화한다.
bytes_transferred는 상대방이 보낸 데이터 크기에 좌우되므로, 이런 코드는 사실상 외부 입력을 다루는 지점이다.
댓글 남기기