본문으로 바로가기

728x90

tunnel.server.zip
1.64MB

 

 

일반적인 네트워크 통신은 TCP를 기반으로 암호화 없이 처리됩니다. 물론, 소켓 자체를 SSL 기반으로 생성된 경우에는 추가적으로 SSL 기반으로 소켓통신을 해야 하는 문제가 있습니다. 예를 들어, 데이터베이스에 접속할 때 데이터베이스가 SSL을 지원하지 않거나 JDBC 드라이버가 SSL 통신을 지원하지 않으면 데이터베이스 값에 대하여 보안을 유지할 수 없습니다.

 

이번 글에서는 SSL을 기반으로 보안네크워크를 구성하여 SSL을 사용하지 않는 환경에서도 터널링을 통하여 네트워크 통신을 보호하는 기능을 설명합니다.

 

SSL 터널링 통신 구조

Service Client에서 Service Server로의 통신은 SSL 통신을 하지 않는 일반적인 TCP 통신으로 가정합니다. 이런 구조에서 Socket Server와 SSL Server를 추가하여 네트쿼크 구간의 보안성을 높일 수 있습니다.

 

주요 로직 설명

1. Socker Server는 TCP통신을 받아서 SSL 통신으로 데이터를 중계합니다.

2. SSL Server는 SSL통신을 받아서 Service Server로 데이터를 중계합니다.

3. 특별한 경우가 아니라면 Service Client와 Socket Server는 로컬 통신을 수행합니다.

4. 특별한 경우가 아니라면 SSL Server와 Service Server는 로컬 통신을 수행합니다.

 

SSL 통신을 위한 보안키 및 CACerts 만들기

Java의 keytool을 사용하여 SSL통신을 위한 RSA 키쌍을 각각 생성하여 keystore를 생성하고 신뢰할 수 있는 인증서에 각각을 keystore에 추가합니다.

keytool -genkey -alias serverkey -keyalg RSA -keypass changeit -storepass changeit -keystore server.keystore
keytool -genkey -alias clientkey -keyalg RSA -keypass changeit -storepass changeit -keystore client.keystore

키 저장소 비밀번호 입력:
새 비밀번호 다시 입력:
이름과 성을 입력하십시오.
  [Unknown]:  ...이름...
조직 단위 이름을 입력하십시오.
  [Unknown]:  ...조직 부서명...
조직 이름을 입력하십시오.
  [Unknown]:  ...조직 이름...
구/군/시 이름을 입력하십시오?
  [Unknown]:  ...주소 2...
시/도 이름을 입력하십시오.
  [Unknown]:  ...주소 1...
이 조직의 두 자리 국가 코드를 입력하십시오.
  [Unknown]:  ...국가코드...
CN="******", OU=******, O=******, L=******, ST=******, C=**이(가) 맞습니까?
  [아니오]:  Y

<serverkey>에 대한 키 비밀번호를 입력하십시오.
        (키 저장소 비밀번호와 동일한 경우 Enter 키를 누름):


keytool -export -alias serverkey -storepass changeit -file server.cer -keystore server.keystore
keytool -export -alias clientkey -storepass changeit -file client.cer -keystore client.keystore

keytool -import -v -trustcacerts -alias serverkey -file .\server.cer -keystore cacerts.keystore -keypass changeit -storepass changeit
keytool -import -v -trustcacerts -alias clientkey -file .\client.cer -keystore cacerts.keystore -keypass changeit -storepass changeit

 

SSL 터널링 구현하기

애플리케이션

파라미터 인자값을 받아서 SSL Server로 동작할지 Socket Server로 작업을 초기화합니다.

파라미터 인자값

구분 설명
args[0] : 동작 구분 SSL Server 또는 Socket Server로 실행 방식 판단
remote : SSL Server로 동작합니다.
local : Socket Server로 동작합니다.
args[1] : 터널링 포트 SSL 통신에 사용되는 포트 지정
remote 모드일 경우에는 SSLServerSocket의 Listening Port로 사용되며 local 모드일 경우에는 SSLSocket로 사용됩니다.
args[2] : 호스트 접속하려는 원격지의 주소
remote 모드일 경우에는 Server Server의 주소, local 모드일 경우에는 SSL Server의 주소 값입니다.
args[3] : 포트 원격지 접속 포트
remote 모드일 경우에는 Server Server의 Port, local 모드일 경우에는 SSL Server의 Port 값입니다.
package kr.ejsoft.tunnel.server;

import java.security.SecureRandom;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Application {
	private static final Logger logger = LoggerFactory.getLogger(Application.class);

	public static void main(String[] args) {
		if(args.length < 4 || (!"local".equals(args[0]) && !"remote".equals(args[0]))) {
			usage();
		}
		
		String mode = args[0];
		int tunnelPort = Integer.parseInt(args[1]);
		String host = args[2];
		int port = Integer.parseInt(args[3]);
		
		new SecureRandom().nextBytes(new byte[1]);
		logger.debug("Initialized Random Number generator.");
		
		TunnelServer tunnelServer;
		if("local".equals(mode)) {
			tunnelServer = new TunnelServer(false, tunnelPort, host, port);
		} else {
			tunnelServer = new TunnelServer(true, tunnelPort, host, port);
		}
		if(tunnelServer != null) tunnelServer.listen();
	}
	
	private static void usage() {
		System.err.println("Usage");
		System.err.println("Server : java kr.ejsoft.tunnel.server.Application remote tunnelPort server-host server-port");
		System.err.println("\t-Djavax.net.ssl.keyStore=server.keystore -Djavax.net.ssl.keyStorePassword=changeit -Djavax.net.ssl.trustStore=server.keystore");

		System.err.println("Client : java kr.ejsoft.tunnel.server.Application local tunnelPort tunnel-remote-server-host listen-port");
		System.err.println("\t-Djavax.net.ssl.keyStore=client.keystore -Djavax.net.ssl.keyStorePassword=changeit -Djavax.net.ssl.trustStore=client.keystore");
		
		System.exit(1);
	}
	
}

터널 서버

클라이언트의 접속을 위한 Listen 서버 소켓을 생성합니다. 원격지 여부에 따라 SSLServerSocket 또는 ServerSocket으로 생성합니다.

package kr.ejsoft.tunnel.server;

import java.net.ServerSocket;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.Socket;
import java.util.Arrays;

import javax.net.ServerSocketFactory;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;

public class TunnelServer {
	private static final Logger logger = LoggerFactory.getLogger(TunnelServer.class);

	private boolean isRemote = false;
	private final String remoteHost;
	private final int remotePort;
	private final int tunnelPort;

	public TunnelServer(boolean isRemote, int tunnelPort, String remoteHost, int remotePort) {
		this.remoteHost = remoteHost;
		this.remotePort = remotePort;
		this.tunnelPort = tunnelPort;
		this.isRemote = isRemote;
	}

	public void listen() {
		try {
			ServerSocket serverSocket = makeServerSocket();
			logger.info("listening...");

			while (true) {
				Socket socket = serverSocket.accept();
				startThread(new TunnelConnection(socket, isRemote, tunnelPort, remoteHost, remotePort));
			}
		} catch (Exception e) {
			throw new RuntimeException(e);
		}
	}

	private void startThread(TunnelConnection connection) {
		Thread t = new Thread(connection);
		t.start();
	}

	private ServerSocket makeServerSocket() {
		try {
			if (this.isRemote) {
				SSLServerSocketFactory factory = (SSLServerSocketFactory) SSLServerSocketFactory.getDefault();
				
				SSLServerSocket serverSocket = (SSLServerSocket) factory.createServerSocket(this.tunnelPort);
				serverSocket.setEnabledProtocols(new String[] {"TLSv1.2"});
				serverSocket.setEnabledCipherSuites(factory.getSupportedCipherSuites());
				serverSocket.setNeedClientAuth(true);
				
				logger.debug("CipherSuites : {}", Arrays.toString(serverSocket.getEnabledCipherSuites()));
				return serverSocket;

			} else {
				return ServerSocketFactory.getDefault().createServerSocket(this.remotePort);
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
		return null;
	}
}

클라이언트 연결 관리

클라이언트의 연결을 개별적으로 관리하는 Thread를 아래와 같이 구현합니다.

package kr.ejsoft.tunnel.server;

import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;

import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TunnelConnection implements Runnable {
	private static final Logger logger = LoggerFactory.getLogger(TunnelConnection.class);

	private boolean isRemote = false;
	private final Socket clientsocket;
	private final String remoteHost;
	private final int remotePort;
	private final int tunnelPort;
	private Socket serverConnection = null;

	public TunnelConnection(Socket clientsocket, boolean isRemote, int tunnelPort, String remoteHost, int remotePort) {
		this.isRemote = isRemote;
		this.clientsocket = clientsocket;
		this.remoteHost = remoteHost;
		this.remotePort = remotePort;
		this.tunnelPort = tunnelPort;
	}

	@Override
	public void run() {
		logger.info("new connection {}:{}", clientsocket.getInetAddress().getHostName(), clientsocket.getPort());
		try {
//			serverConnection = new Socket(host, remotePort);
			serverConnection = connect();
		} catch (IOException e) {
			e.printStackTrace();
			return;
		}

		logger.info("Proxy {}:{} <-> {}:{}", clientsocket.getInetAddress().getHostName(), clientsocket.getPort(), serverConnection.getInetAddress().getHostName(), serverConnection.getPort());

		new Thread(new TunnelStream(clientsocket, serverConnection)).start();
		new Thread(new TunnelStream(serverConnection, clientsocket)).start();
		new Thread(() -> {
			while (true) {
				if (clientsocket.isClosed()) {
					logger.info("client socket ({}:{}) closed", clientsocket.getInetAddress().getHostName(), clientsocket.getPort());
					closeServerConnection();
					break;
				}

				try {
					Thread.sleep(1000);
				} catch (InterruptedException ignored) {
				}
			}
		}).start();
	}

	private void closeServerConnection() {
		if (serverConnection != null && !serverConnection.isClosed()) {
			try {
				logger.info("closing remote host connection {}:{}", serverConnection.getInetAddress().getHostName(), serverConnection.getPort());
				serverConnection.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	
	private Socket connect() throws UnknownHostException, IOException {
		if(this.isRemote) {
			Socket socket = new Socket(this.remoteHost, this.remotePort);
			return socket;
		} else {
			SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
			SSLSocket socket = (SSLSocket) factory.createSocket(this.remoteHost, this.tunnelPort);
			socket.setEnabledProtocols(new String[] { "TLSv1.2" });
			return socket;
		}
	}
}

데이터 송수신 처리

수신되는 데이터와 송신되는 데이터의 In/Out Stream을 처리하는 Thread를 구현합니다.

package kr.ejsoft.tunnel.server;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.net.SocketException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TunnelStream implements Runnable {

	private static final Logger logger = LoggerFactory.getLogger(TunnelStream.class);
	private final Socket in;
	private final Socket out;

	public TunnelStream(Socket in, Socket out) {
		this.in = in;
		this.out = out;
	}

	@Override
	public void run() {
		logger.info("Proxy {}:{} --> {}:{}", in.getInetAddress().getHostName(), in.getPort(), out.getInetAddress().getHostName(), out.getPort());

		try {
			InputStream inputStream = getInputStream();
			OutputStream outputStream = getOutputStream();

			if (inputStream == null || outputStream == null) {
				return;
			}

			byte[] reply = new byte[4096];
			int bytesRead;
			while (-1 != (bytesRead = inputStream.read(reply))) {
				outputStream.write(reply, 0, bytesRead);
			}
		} catch (SocketException ignored) {
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			try {
				in.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

	private InputStream getInputStream() {
		try {
			return in.getInputStream();
		} catch (IOException e) {
			e.printStackTrace();
		}

		return null;
	}

	private OutputStream getOutputStream() {
		try {
			return out.getOutputStream();
		} catch (IOException e) {
			e.printStackTrace();
		}

		return null;
	}
}

 

 

반응형

 

자동 시작하기

Windows 자동 시작하기

일괄 실행파일(. bat)을 다음과 같이 생성합니다.

@echo off

SET CLAZZ=kr.ejsoft.tunnel.server.Application

SET BASEPATH=.........

SET CLASSPATH=kr.ejsoft.tunnel.server-0.0.1-SNAPSHOT.jar
SET CLASSPATH=%CLASSPATH%;log4j-api-2.10.0.jar
SET CLASSPATH=%CLASSPATH%;log4j-core-2.10.0.jar
SET CLASSPATH=%CLASSPATH%;log4j-slf4j-impl-2.10.0.jar
SET CLASSPATH=%CLASSPATH%;slf4j-api-1.8.0-alpha2.jar

SET OPTIONS=-Djavax.net.ssl.keyStore=client.keystore
SET OPTIONS=%OPTIONS% -Djavax.net.ssl.keyStorePassword=changeit
SET OPTIONS=%OPTIONS% -Djavax.net.ssl.trustStore=cacerts.keystore
SET OPTIONS=%OPTIONS% -Djavax.net.ssl.trustStorePassword=changeit

SET COMMAND=java -classpath %CLASSPATH% %OPTIONS% %CLAZZ% {local} {tunnel-port} {host} {port}

CD %BASEPATH%

ECHO %COMMAND%

REM @START /b cmd /c %COMMAND%

@START /b %COMMAND%

exit 0

시작 시 백그라운드로 실행하기 위한 vbs 파일을 생성합니다.

Set objShell = CreateObject("Shell.Application")
objShell.ShellExecute "........\tunnel.bat", "/c lodctr.exe /r" , "", "runas", 0

윈도의 자동시작을 위해 레지스트리에 문자열 값을 신규로 등록합니다.

\HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Run

아래의 이미지와 같이 신규 문자열 값을 추가합니다.

Linux 자동 시작하기

아래의 설명은 CentOS 7을 기준으로 설명합니다.

자동 시작을 위한 쉘 스크립트를 생성합니다.

#!/bin/sh
PID=/var/run/tunnel.pid
LOG=/var/log/tunnel.log

CLAZZ="kr.ejsoft.tunnel.server.Application"
BASEPATH="/root/tunnel.server"

CLASSPATH="${BASEPATH}/kr.ejsoft.tunnel.server-0.0.1-SNAPSHOT.jar"
CLASSPATH="${CLASSPATH}:${BASEPATH}/log4j-api-2.10.0.jar"
CLASSPATH="${CLASSPATH}:${BASEPATH}/log4j-core-2.10.0.jar"
CLASSPATH="${CLASSPATH}:${BASEPATH}/log4j-slf4j-impl-2.10.0.jar"
CLASSPATH="${CLASSPATH}:${BASEPATH}/slf4j-api-1.8.0-alpha2.jar"

OPTIONS="-Djavax.net.ssl.keyStore=${BASEPATH}/server.keystore"
OPTIONS="${OPTIONS} -Djavax.net.ssl.keyStorePassword=changeit"
OPTIONS="${OPTIONS} -Djavax.net.ssl.trustStore=${BASEPATH}/cacerts.keystore"
OPTIONS="${OPTIONS} -Djavax.net.ssl.trustStorePassword=changeit"
#OPTIONS="${OPTIONS} -Djavax.net.debug=all"

COMMAND="java -classpath ${CLASSPATH} ${OPTIONS} ${CLAZZ} remote tunnel-port host port"


status() {
    # echo
    # echo "==== Status"

    if [ -f $PID ]
    then
        # echo
        echo "Pid file: $( cat $PID ) [$PID]"
        # echo
        ps -ef | grep -v grep | grep $( cat $PID )
    else
        # echo
        echo "No Pid file"
    fi
}

start() {
    if [ -f $PID ]
    then
        # echo
        echo "Already started. PID: [$( cat $PID )]"
    else
        # echo "==== Start"
        touch $PID
        if nohup $COMMAND >>$LOG 2>&1 &
        then echo $! >$PID
             echo "Started..."
             # echo "$(date '+%Y-%m-%d %X'): START" >> $LOG
        else echo "Error... "
             /bin/rm $PID
        fi
    fi
}

kill_cmd() {
    SIGNAL=""; MSG="Killing "
    while true
    do

        # LIST=`ps -ef | grep -v grep | grep $CMD | grep -w $USR | awk '{print $2}'`
        LIST=`ps -ef | grep -v grep | grep $CLAZZ | awk '{print $2}'`
        if [ "$LIST" ]
        then
            echo; echo "$MSG $LIST"
            # echo
            echo $LIST | xargs kill $SIGNAL
            sleep 2
            SIGNAL="-9" ; MSG="Killing $SIGNAL"
            if [ -f $PID ]
            then
                /bin/rm $PID
            fi
        else
           # echo; echo "All killed..." ; echo
           break
        fi
    done
}

stop() {
    # echo "==== Stop"

    if [ -f $PID ]
    then
        if kill $( cat $PID )
        then
             echo "Stoped...."
             # echo "$(date '+%Y-%m-%d %X'): STOP" >>$LOG
        fi
        /bin/rm $PID
        kill_cmd
    else
        echo "No pid file. Already stopped?"
    fi
}

case "$1" in
    'start')
            start
            ;;
    'stop')
            stop
            ;;
    'restart')
            stop ; echo "Sleeping..."; sleep 1 ;
            start
            ;;
    'status')
            status
            ;;
    *)
            echo
            echo "Usage: $0 { start | stop | restart | status }"
            echo
            exit 1
            ;;
esac

exit 0


서비스 등록을 위해 service 파일을 생성합니다.

vi /etc/systemd/system/tunnel.service

경로는 필요에 맞게 적절하게 수정합니다.

[Unit]
Description=Tunnel Server
After=network.target

[Service]
Type=simple
PIDFile=/var/run/tunnel.pid
ExecStart=/root/tunnel.server/tunnel.sh start
ExecStop=/root/tunnel.server/tunnel.sh stop
ExecRestart=/root/tunnel.server/tunnel.sh restart
Restart=on-failure

[Install]
WantedBy=multi-user.target

서비스를 자등 실행으로 등록하고 실행합니다.

systemctl enable tunnel
systemctl start tunnel

 

 

글을 마치며

본 코드는 서버의 주요 로직은 참고자료의 TCP Proxy를 기반으로 만들었습니다. 그리고 SSL 통신에 대한 내용은 [Professional Java Security] 도서의 JDBC 보안 부분을 참고하여 만들었습니다. 참고로 본 서버는 통신 구간의 데이터를 암호화하여 제3자가 어떤 프로토콜의 통신인지 알 수 없도록 처리하여 소프트웨어 방화벽(통신 프로토콜을 분석하여 네트워크를 차단 정책)을 우회할 수 있습니다. 

Professional Java Security

 

소스코드

본 글의 소스코드는 여기 또는 본 글의 첨부파일에서 다운로드 가능합니다.

 

참고자료

 

 

 

728x90