Autoryzacja komunikacji Client-Server przy użyciu protokołu Port knocking.

0

Cześć, chciałbym prosić o code review małego projektu.
Założenia:

  • napisanie dwóch programów klienta oraz serwera, które używają autoryzacji przy użyciu protokołu Port Knocking(https://en.wikipedia.org/wiki/Port_knocking).
  • serwer powinien być w stanie obsłużyć wiele zapytań równocześnie
  • serwer otwiera określoną liczbę porów UDP, a następnie zaczyna na nich nasłuchiwać na przychodzące pakiety
  • jeżeli jeden klient (taki sami adres i port ) wyśle odpowiednią sekwencje zapytań, wtedy serwer wysyła do tego klienta adres portu TCP na
    którym będzie nasłuchiwać na komunikacje od niego,
  • klient inicjalizuje komunikacje TCP na tym porcie,
  • po zakończeniu komunikacji port jest zamykany.

Stworzyłem więc klasę Server który zawiera moduł autoryzacyjny

public class Server {
    private Authorization authorization;


    public static void main(String[] args) throws IOException {
        new Server(new int[]{8000, 9000, 10000, 11000, 12000, 15555});

    }

    private Server(int[] portNumbers) throws SocketException {
        authorization = new Authorization(portNumbers, this);

    }
}

Uznałem, że wygodniej będzie jeżeli autoryzacja będzie odbywała się w osobnym module. Do serwera będą przekazywane tylko dane klientów, którzy autoryzacje uzyskali.
Klasa Authorization przyjmuje tablice z sekwencją portów( na które klient musi wysłać datagramy, by uzyskać autoryzacje), oraz obiekt Server( do którego będą wysyłane dane klientów którzy autoryzacje przeszli). Zawiera zbiór klientów którzy są w trakcie autoryzacji.

public class Authorization {

    private Server server;
    private int[] portNumbers;
    private Set<Client> clientSet = new HashSet<>();

    public Authorization(int[] portNumbers, Server server) throws SocketException {
        this.portNumbers = portNumbers;
        this.server = server;
        createSocketListeners();

    }

Authorization tworzy wątki które na każdym z portów nasłuchują na przychodzące datagramy.

    private void createSocketListeners() throws SocketException {
        byte[][] buffers = new byte[portNumbers.length][256];
        DatagramPacket[] packets = new DatagramPacket[portNumbers.length];
        DatagramSocket[] sockets = new DatagramSocket[portNumbers.length];

        for (int i = 0; i < portNumbers.length; i++) {
            packets[i] = new DatagramPacket(buffers[i], buffers[i].length);
            sockets[i] = new DatagramSocket(portNumbers[i]);
            new Thread(new SocketListener(packets[i], sockets[i], this, portNumbers[i])).start();
        }
    }

Klasa SocketListner otrzymuje port UDP i nasłuchuje na nim przychodzących pakietów. Gdy do portu przyjdzie pakiet, socketListner zapisuje z jakiego adresu oraz portu on pochodzi. Następnie wysyła te dane z powrotem do authorization.

class SocketListener implements Runnable {
    private DatagramPacket packet;
    private DatagramSocket socket;
    private int portNumber;
    private Authorization authorization;

    SocketListener(DatagramPacket packet, DatagramSocket socket,
                          Authorization authorization, int portNumber) {
        this.packet = packet;
        this.socket = socket;
        this.authorization = authorization;
        this.portNumber = portNumber;
    }

    @Override
    public void run() {
        while (true) {
            try {
                socket.receive(packet);
                ClientDTO c = new ClientDTO(packet.getAddress(),packet.getPort());
                authorization.processClient(c, this);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

    }

Klasa authorization w metodzie procesClient przyjmuje obiekt ClientDTO oraz instancje SocketListenera, który otrzymał datagram. Na podstawie ClientDTO, i sekwencji portów tworzy obiekt Client. Następnie sprawdza czy klient o takim adresie jest już w trakcji autoryzacje. W przeciwnym razie dodaje go do zbioru. Kolejnym krokiem jest wywołanie w obiekcie klienta metody, która sprawdza czy sekwencja autoryzacji została ukończona. Jeżeli tak to obiekt ten zostaje przekazany do serwera jako zaufany.
Zastosowałem tu trochę dziwną konstrukcje, z iteratorem. Chodzi o to żeby do zmiennej client, do której na początku przypisywany jest nowy obiekt klienta, przypisać obiekt clienta ze zbioru ( jeżeli taki się tam znajduje). Później to właśnie na zmiennej client wywoływana jest metoda, sprawdzająca czy sekwencja została ukończona. Spowodowane jest to ograniczeniem interfejsu Set, z którego nie można bezpośrednio pobierać instancji obiektów. To jest jedno z miejsc do ewentualnej poprawki, może trzeba zamiast Set użyć Map?

    synchronized void processClient(ClientDTO dto, SocketListener listener) {

        Client client = new Client(dto, getSequence());

        boolean contains = false;
        for (Client c : clientSet) {
            if (c.equals(client)) {
                client = c;
                contains = true;
            }
        }

        if (!contains){
            clientSet.add(client);
        }

        if (client.moveOnSequence(listener.getPortNumber())) {
            clientSet.remove(client);
            server.setAuthorizedClient(client);
        }

    }

Sekwencja, o której mowa w nazwie metody to Queue<Integers>, gdzie Integers są po prostu kolejnymi numerami porów sekwencji. Metoda
ta przyjmuje numer portu, na który klient wysłał datagram. Jeżeli port ten jest taki sam jak ten który znajduje się na początku kolejki. Numer tego portu jest usuwany z kolejki i sekwencja "idzie do przodu". Gdy z kolejki zostaną usunięte wszystkie numery portów, zakłada się że prawidłowa sekwencja została wysłana. Klient może uzyskać autoryzacje. Jeżeli numer portu nie będzie się zgadał to sekwencja zostanie cofnięta do momentu początkowego.

    synchronized boolean moveOnSequence(int portNumber) {



        if ( sequence != null && sequence.peek().equals(portNumber) ){
            sequence.poll();
            if ( sequence.isEmpty()) {
                return  true;
            }
        } else{
//            resetSequence();
        }
        return false;
    }

Jeżeli klient otrzymał autoryzacje jego instancja zostaje przekazana do obiektu Server, który losuje numer portu na którym będzie nasłuchiwał, oraz tworzy wątek obsługujący komunikacje z serwerem na tym porcie.

    void setAuthorizedClient(Client client) {
        int randomPortNumber = (int) (Math.random() * 50000) + 10000;

        new Thread(new AuthorizedConnection(randomPortNumber, this, client)).start();
    }

AuthorizedConnection tworzy port UDP z którego wysyła do klienta numer portu TCP na którym będzie nasłuchiwać na komunikacje od niego. Gdy komunikacje zostanie utworzona, klient może żądać zasobów serwera.

  @Override
    public void run() {
         sendPortNumberByUDP();
         TcpCommunication();

    }

    private void TcpCommunication(){

        try (
                ServerSocket serverSocket = new ServerSocket(portNumber);
                Socket clientSocket = serverSocket.accept();
                PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
                BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
        ) {

            String inputLine, outputLine;
            while ((inputLine = in.readLine()) != null) {
                System.out.println(inputLine);
                if ((outputLine = server.getRecourse(inputLine)) != null){
                    out.println(outputLine);
                    break;
                }
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    private void sendPortNumberByUDP() {
         try (DatagramSocket socket = new DatagramSocket()){
             byte[] portMessage = String.valueOf(portNumber).getBytes();
             byte[] buf = new byte[256];
             DatagramPacket datagramPacket = new DatagramPacket(buf,buf.length,client.getInetAddress(),client.getPortNumber());
             datagramPacket.setData(portMessage);
             socket.send(datagramPacket);
         } catch (IOException e){
             e.printStackTrace();
         }
    }

Procesu Klienta nie będę przedstawiał, jest bardzo prosty. Wysyła datagramy na odpowiednie porty, oczekuje na numer portu TCP, otwiera komunikacje TCP na danym porcie i może wysyłać zapytania.
Chciałbym prosić o ocenę poprawności podejścia. Czy założenia są spełnione? Czy program napisany jest zgodnie ze sztuką, oraz co trzeba w nim poprawić by z tą sztuką był zgodny. Problem wydawał mi się ciekawy, dlatego chciałbym go dobrze zrozumieć. Jeżeli opis jest w którymś miejscu nie jasny, postaram się go jak najszybciej poprawić.

P.S Jestem młodym programistą, dlatego niektóre rzeczy mogą być dla mnie nieoczywiste :P.

2

Dramat o_O

  1. new Thread(new SocketListener(packets[i], sockets[i], this, portNumbers[i])).start();
    public void run() {
        while (true) {
            try {
                socket.receive(packet);
                ClientDTO c = new ClientDTO(packet.getAddress(),packet.getPort());
                authorization.processClient(c, this);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

Obstawiam ze po kilku klientach twój komputer stanie w płomieniach o będzie miał 100% CPU. Raz ze spawnujesz tyle wątków ile requestów dostaniesz (zamiast użyć jakiegoś thread pool i executora) a dwa że z jakiegoś powodu robisz tam while true które nigdy się nie kończy. Ratuje cię tu przypadkiem blokujące I/O, ale sprytny klient mógłby celowo wysyłać ci jakieś dane żeby ubić ci serwer.

  1. Cały ten kod nie ma sensu bo masz tam JEDEN datagram packet do którego odbierasz dane z wielu klientów. Przecież to książkowy race condition. Co sie stanie jak jednocześnie obsługujesz 2 klientów? Przecież receive jednego nadpisze temu drugiemu :D Każdy klient musi dostać "swój" bufor na odbierane dane!
  2. Cała ta pętla sprawdzająca czy ClientDTO jest w Set jest bez sensu. Po to masz tam set że możesz po prostu zrobić .add(). Czy ty w ogóle rozumiesz na czym polega Set?
  3. Musisz tam mieć jakieś "wygasanie" bo inaczej raz ze ktoś może to łatwo DoSować, bo lista klientów będzie ci puchła. Jakieś Cache z Caffeine z wygasaniem po jakimś czasie by się tu przydało.
  4. Można to zbrutować bo nie robisz nic kiedy klient się pomyli. Więc mogę po prostu na pałę wysłać pakiety 6 razy na wszystkie porty i przejdę ten proces.
0

Jak słusznie zostało zauważone przez @init0 jest to zadanie ze studiów.
Sposób działania
Aplikacja składa sie z dwóch procesów, serwera i klienta.
1 W pierwszym rzedzie nalezy uruchomic proces serwera. Proces ten otwiera zadana
parametrem liczbę portów UDP, a nastepnie zaczyna na nich nasłuchowac na pakiety
od klientów. Jesli zostanie wykryta odpowiednia sekwencja pakietów UDP wysyłanych
z tego jednego adresu (ten sam adres IP i port), otwiera losowo wybrany port
TCP i na adres z którego te pakiety przychodziły, wysyła komunikat UDP z numerem
tego portu TCP. Nastepnie oczekuje połaczenia TCP i po zakonczeniu prostej
komunikacji (typu zapytanie odpowiedz) rozłacza sie, po czym ponownie zaczyna
nasłuchiwac na portach UDP.
2 Aplikacja klienta przyjmuje jako parametry adres serwera i numery portów, na które
ma “pukac”. Po uruchomieniu otwiera port UDP, z którego wysyła serie pakietów
na kolejne porty podane jako parametry. Po ich wysłaniu oczekuje na komunikat
zwrotny zawierający numer portu TCP, z którym to następnie nawiązuje połaczenie.
Po wykonaniu prostej komunikacji z serwerem (typu zapytanie-odpowiedz) rozłacza
sie i konczy prace.
Jesli sekwencja pakietów UDP jest niepoprawna, odpowiedz od serwera nie przyjdzie.
W takim przypadku, po okreslonym czasie (timeout) klient konczy prace z komunikatem
błedu.

0

@Shalom:
Ad 1. Moim pomysłem było stworzenie osobnego wątku na każdy port należący do sekwencji, jak coś na niego przyjdzie to wysyła to do autoryzacji która już się tym zajmuje po swojemu. Autoryzacja ma działać cały czas dlatego jest tam while(true). Rzeczywiście nie pomyślałem, że kolejny recive może nadpisać dane.
Ad. 4. Nie ma możliwości wyciągnięcia konkretnego elementu z Setu bez przeiterownia przez wszystkie elementy. A o to mi właśnie chodziło, żeby jeżeli taki klient znajduje się w secie to użyć tego obiektu który już się tam znajduje. Dlatego set chyba nie bardzo tam pasuje spróbuje użyć mapy.
Ad. 6 Można to zbrutować, ale jeżeli nie znasz sekwencji tych portów będzie to bardzo trudne. Masz 65535 możliwości na jeden port, a nie wiesz ile ich jest w całej sekwencji.

Przemyślę Twoje uwagi i will be back. Tylko pytanie czy takie rozwiązanie ma w ogóle sens czy myśleć na innym podejściem.

0

Przemyślałem uwagi oraz zmieniłem mechanizm działania programu. Nie wszystko udało mi się uwzględnić. Nie dodałem jeszcze wygasania klientów oraz nie wprowadziłem jeszcze mechanizmu który by blokował klienta, który się pomylił. Prawdę mówiąc to nie wiem jak to zrobić. Musiał by być chyba oddzielny catche na zablokowanych, a porty UDP sprawdzały by czy datagram nie pochodzi z zablokowanego adresu.

Program wygląda teraz tak:

  1. jest fixedTreadPool który obsługuje porty UDP ( wątki SocketListener, ich liczba jest znana, a same wątki nie kończą się nigdy.
  2. jest Executors.newCachedThreadPool() do którego SocketListener zlecają datagramy zaraz po otrzymaniu
  3. Executor z ptk. 2 uruchamia wątki PacketProcessor które przetwarzają jeden datagram i umierają. Jak będzie rosła liczą datagramów to będą spawnowane nowe watki.
  4. jest statyczna hash mapa która śledzi postępy klientów w sekwencji ( hasuje stringa zrobionego z numeru ip i portu)
public class Authorization {


    private final Queue<Integer> sequence = new LinkedList<>();
    private ExecutorService packetProcessors;
    private Server server;



    public Authorization(int[] portNumbers, Server server) throws SocketException {
        this.server =server;
        constructSequence(portNumbers);
        ExecutorService socketListeners = Executors.newFixedThreadPool(portNumbers.length);
        packetProcessors = Executors.newCachedThreadPool();
        DatagramSocket[] sockets = new DatagramSocket[portNumbers.length];

        for (int i = 0; i < portNumbers.length ; i++) {
            sockets[i] = new DatagramSocket(portNumbers[i]);
            socketListeners.execute(new SocketListener(sockets[i], portNumbers[i], this));
        }

    }

    private void constructSequence(int[] portNumbers){
        for ( int port : portNumbers) {
            sequence.add(port);
        }
    }

    void setAuthorizedClient(Client client){
        server.setAuthorizedClient(client);
    }

    void processDatagram(DatagramPort data){
       packetProcessors.submit( new PacketProcessor(this,sequence,data));
    }

}
public class SocketListener implements Runnable {
    private DatagramSocket socket;
    private final int PORT_NUMBER;
    private Authorization authorization;

    public SocketListener(DatagramSocket socket, int PORT_NUMBER, Authorization authorization) {
        this.socket = socket;
        this.authorization = authorization;
        this.PORT_NUMBER = PORT_NUMBER;
    }

    @Override
    public void run() {
        try {
            while (!Thread.interrupted()) {
                DatagramPacket p = getNewPacket();
                System.out.println(p.getAddress() + " " + p.getPort());
                authorization.processDatagram(new DatagramPort(p, PORT_NUMBER));

            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    private synchronized DatagramPacket getNewPacket() throws IOException {
        byte[] buf = new byte[1];
        DatagramPacket p = new DatagramPacket(buf, buf.length);
        socket.receive(p);
        return p;
    }
public class PacketProcessor implements Runnable {
    private static Map<Integer, Client> clientMap = new Hashtable<>();
    private Authorization authorization;
    private final Queue<Integer> sequence;
    private DatagramPort datagramPort;

    public PacketProcessor(Authorization authorization, Queue<Integer> sequence, DatagramPort port) {

        this.authorization = authorization;
        this.sequence = sequence;
        this.datagramPort= port;
    }

    @Override
    public void run() {


        int hash = hashCode(datagramPort);
        System.out.println(hash);
        if (isClientAlreadyInMap(hash)) {
            processClient(hash, datagramPort.getSocketPortNumber());
        } else {
            System.out.println("creating");
            Client client =
                    new Client(datagramPort.getPacket().getAddress(),
                            datagramPort.getPacket().getPort(),
                            new LinkedList<>(sequence));
            addClientToMap(hash, client);
            processClient(hash,datagramPort.getSocketPortNumber());
        }


    }

    private synchronized void processClient(int hash, int socketPortNumber) {
        Client client = getClientFromMap(hash);
        client.moveSequence(socketPortNumber);
        if (client.getState() == ClientState.AUTHORIZED) {
            System.out.println("Authorization granted");
            authorization.setAuthorizedClient(client);
            removeFromMap(hash);
        }
    }

    private synchronized static boolean isClientAlreadyInMap(int hash) {
        return clientMap.containsKey(hash);
    }

    private synchronized static Client getClientFromMap(int hash) {
        return clientMap.get(hash);
    }

    private synchronized static void addClientToMap(int hash, Client client) {
        clientMap.put(hash, client);
    }

    private synchronized static void  removeFromMap(int hash){
        System.out.println("removing");
        clientMap.remove(hash);

    }

    private int hashCode(DatagramPort port){
       return  (port.getPacket().getAddress().toString() + "/" + port.getPacket().getPort()).hashCode();
    }


}
public class Client {
    private ClientState state = ClientState.UNAUTHORIZED;
    private final InetAddress address;
    private final int clientPortNumber;
    private Queue<Integer> sequence;

    public Client(InetAddress address, int clientPortNumber, Queue<Integer> sequence) {
        this.address = address;
        this.clientPortNumber = clientPortNumber;
        this.sequence = sequence;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Client)) return false;
        Client client = (Client) o;
        return clientPortNumber == client.clientPortNumber &&
                address.equals(client.address);
    }

    @Override
    public int hashCode() {
        return Objects.hash(address, clientPortNumber);
    }

    public ClientState getState() {
        return state;
    }

    public  void moveSequence(int socketPortNumber) {

        if (state != ClientState.UNAUTHORIZED) {
            return;
        }

        if (sequence.peek() == socketPortNumber) {
            sequence.poll();

            if (sequence.isEmpty()) state = ClientState.AUTHORIZED;
        } else {
             state = ClientState.BLOCKED;
        }
    }

    public InetAddress getAddress() {
        return address;
    }

    public int getClientPortNumber() {
        return clientPortNumber;
    }
}
}
0

Zmierzam w lepszym kierunku czy dalej tragedia? Nie wiem w jaki sposób to wiarygodnie przetestować. Testuje to lokalnie. Tu mam dostępne tylko 4 rdzenie więc bardzo dużo zależy od tego jaki proces dostanie swój kawałek czasu procesora. Puszczam "równocześnie" 100 klientów którzy chcą się zautoryzować. Parametr który zmieniam w to czas jaki wątek klienta śpi pomiędzy wysłaniem kolejnego datagramu w milisekundach.
AuthorisationSuccesPercentage.JPG
(Oś pionowa procent udanych autoryzacji, oś pozioma Client Thread sleep time )
Ilość powtórzeń dla każdego czasu t, to 20. Punkty na wykresie to średnia arytmetyczna z tych prób.

Próbowałem to postawić u siebie na kompie, a klientów wypuszczać od brata z drugiego końca miasta. Nie udaję mi się nawiązać połączenia. Pardwę mówiąc nawet traceroute się gubi i nie dochodzi do niego. Z chęcią przyjmę kolejne sugestię, poprzednie bardzo mi pomogły.

1

private synchronized DatagramPacket getNewPacket() throws IOException {

Po co niby to synchronized? Masz jeden socket per wątek przecież. Tak samo te wszystkie static synchronized co tam gdzieś masz. I w ogóle wszystko static -> po co?
Nie wiem też po co jakieś cuda z jakimś hash bo mapa sama sobie umie policzyć hash. Poza tym Map ma coś takiego jak computeIfAbsent na przykład. W ogóle robisz tam jakieś cyrki z synchronized w miejscach z d**y, a tam gdzie faktycznie by się przydało, to robisz TOC-TOU :D
Klientów do ConcurrentHashMap a tworzenie nowego klienta przez computeIfAbsent a to Queue też nie będzie raczej ConcurrentLinkedQueue a nie LinkedList, bo przyjdą dwa requesty od tego samego usera i znowu się posypie coś ;)

1 użytkowników online, w tym zalogowanych: 0, gości: 1