Esercitazione sulla gestione dei processi

L’esercitazione di oggi consiste nell’implementazione di un programma C che parallelizza su più core il cracking di una lista di hash SHA-256. L’archivio (link) contiene tre versioni di un cracker che implementa un attacco bruteforce basato su dizionario:

  • cracker-x86: Linux, 32 bit;
  • cracker-x86-64: Linux, 64 bit;
  • cracker-macOS: macOS.

Il cracker riceve l’hash da crackare come argomento da riga di comando. Se l’operazione ha avuto successo, il cracker termina con exit(0) e inserisce la parola corrispondente all’hash nel file output_PID.txt dove PID è il process identifier del cracker. Se l’operazione fallisce, il cracker termina con exit(1).

Gli hash da crackare sono specificati come parametri da riga di comando. Il vostro programma dovrà eseguire un’istanza del cracker per ciascun hash, attendere la terminazione dei vari processi e stampare, per ciascun hash, la corrispondente stringa se il cracking ha avuto successo. Infine dovete stampare il numero di hash crackati rispetto al numero totale.

Per le operazioni sui file utilizzate le funzioni open, read e close al posto di quelle presenti nella libreria standard di C che già conoscete (fopen, fread, fscanf, etc). In questo modo potete iniziare a familiarizzare con il loro utilizzo, dal momento che le useremo anche per le operazioni sulle pipe che vedremo nelle prossime lezioni. Eccone una breve descrizione (semplificata):

  • int open(const char *path, int oflag): apre il file indicato dalla stringa path. Il valore del parametro oflag permette di controllare vari aspetti dell’apertura del file, inclusa la scelta di aprirlo in lettura e/o scrittura: per aprire il file in lettura usate la costante O_RDONLY come primo parametro. In caso di successo la funzione restituisce un numero non negativo che identifica il file aperto, noto come file descriptor. Se l’operazione di apertura fallisce, la funzione restituisce -1.
  • ssize_t read(int fildes, void *buf, size_t nbyte): legge al più nbyte bytes dal file identificato dal descrittore fildes e li inserisce nel buffer buf. Il valore di ritorno è il numero di byte letti da file; quando non sono presenti ulteriori byte da leggere, la funzione restituisce 0.
  • int close(int fildes): chiude il file identificato dal descrittore fildes.

Per ulteriori informazioni su una funzione fate riferimento alla corrispondente pagina del manuale che potete visualizzare con il comando man 2 NOME_FUNZIONE.

Ecco un esempio di esecuzione del cracker multicore e dell’output che deve produrre:

Verificate che il vostro programma sia effettivamente più veloce rispetto ad un’implementazione che non distribuisce il cracking degli hash su più core. Per misurare il tempo di esecuzione di un programma potete usare il comando time come segue:

Limitare il numero di processi

La soluzione che avete implementato soffre di un piccolo problema: quando il numero di hash da crackare è elevato, il vostro programma “inonda” il sistema di processi cracker, rendendolo potenzialmente inutilizzabile. Implementate una variante del programma che lancia in contemporanea al massimo N cracker, dove N è un parametro fornito dall’utente (ad esempio come primo parametro da riga di comando). Se il numero di hash da crackare è superiore a N, la vostra implementazione dovrà attendere la terminazione di uno dei cracker in esecuzione prima di lanciare un nuovo processo.

Verificate come varia il tempo di esecuzione in base al numero di cracker in esecuzione. È sempre vantaggioso aumentare il numero di cracker? Perché?

Soluzione

Segue un esempio di soluzione del cracker multicore dove il limite massimo di processi da crackare è specificato dall’utente come primo parametro da riga di comando.

#include <stdlib.h>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
#define BLOCK_SIZE 16

/* Leggi il contenuto del file prodotto dal figlio con process
 * identifier 'pid'. */
char* read_file(pid_t pid) {
    char c, *buf, *fname;
    int fd, idx, stop, fname_size, buf_size;

    /* Calcola la dimensione del nome del file. Successivamente alloca un
     * buffer della dimensione necessaria e scrivi al suo interno il nome
     * del file. */
    fname_size = snprintf(NULL, 0, "output_%d.txt", pid) + 1;
    fname = malloc(sizeof(char) * (fname_size+1));
    snprintf(fname, fname_size, "output_%d.txt", pid);

    /* Apri il file. Ritorna NULL in caso di errore (questa situazione non
     * dovrebbe mai accadere). */
    fd = open(fname, O_RDONLY);
    if (fd < 0) {
        return NULL;
    }
    
    /* Leggi dal file un byte alla volta. Quando il buffer in cui inserire
     * il contenuto del file è pieno, alloca ulteriori BLOCK_SIZE byte. Il
     * ciclo termina quando la funzione 'read' ritorna 0, ovvero il file è
     * stato completamente letto. */
    buf = NULL;
    buf_size = idx = stop = 0;
    while (!stop) {
        stop = read(fd, &c, sizeof(char)) == 0;
        if (stop) {
            c = '\0';
        }
        if (idx >= buf_size) {
            buf_size += BLOCK_SIZE;
            buf = realloc(buf, buf_size * sizeof(char));
        }
        buf[idx++] = c;
    }

    /* Chiudi il file e rimuovilo dal filesystem. */
    close(fd);
    unlink(fname);
    free(fname);
    
    return buf;
}

/* Attendi la terminazione di un processo. Stampa l'hash che il processo
 * terminato doveva crackare e, se terminato con successo, stampa a video
 * il testo in chiaro. Ritorna 1 se il cracker ha terminato con successo,
 * 0 se il cracker ha fallito o non ci sono altri processi da attendere. */
int wait_child(pid_t crackers[], char *hashes[]) {
    pid_t pid;
    char *plain;
    int i, status;

    /* Attendi la terminazione di un processo. Ritorna 0 se il processo
     * non è terminato normalmente, ad esempio a causa di un segnale. */
    pid = wait(&status);
    if (pid < 0 || !WIFEXITED(status)) {
        return 0;
    }

    /* Cerca l'hash che doveva essere crackato dal processo che è appena
     * terminato. Sebbene non ci sia alcun controllo per evitare di uscire
     * dal vettore, siamo sicuri che non si verificherà alcun overflow! */ 
    for (i = 0; crackers[i] != pid; i++) {}
    /* Leggi da file e stampa il testo corrispondente all'hash se il processo
     * è terminato con successo, altrimenti stampa un messaggio d'errore. */
    if (WEXITSTATUS(status) == 0) {
        plain = read_file(pid);
        printf("[OK] %s -> %s\n", hashes[i+2], plain);
        free(plain);
    } else {
        printf("[FAIL] %s -> boh :(\n", hashes[i+2]);
    }

    return 1 - WEXITSTATUS(status);
}

int main(int argc, char *argv[]) {
    int i, success, total, running, max_procs;
    pid_t *crackers, pid;

    if (argc < 3) {
        fprintf(stderr, "Usage: ./multicore MAX_PROCS HASH [HASH...]\n");
        exit(EXIT_FAILURE);
    }

    /* Leggi il numero di processi passato da riga di comando. Termina il
     * programma se il valore fornito è invalido. */
    max_procs = atoi(argv[1]);
    if (max_procs <= 0) {
        fprintf(stderr, "Invalid number of processes.\n");
        exit(EXIT_FAILURE);
    }
    /* Questo vettore serve per tenere traccia di quale processo sta
     * crackando un certo hash. In particolare, la posizione 'i' contiene
     * il PID del processo che sta crackando l'hash 'argv[i+2]'. */
    crackers = malloc(sizeof(pid_t) * (argc-2));
    
    running = success = 0;
    for (int i = 2; i < argc; i++) {
        /* Attendi la terminazione di un cracker se il numero di processi
         * in esecuzione è pari al massimo specificato dall'utente. */
        if (running >= max_procs) {
            success += wait_child(crackers, argv);
            running--;
        }
        /* Crea un processo cracker per ogni hash passato da riga di comando.
         * Qualora una fork dovesse fallire, l'istruzione 'kill' invia un segnale
         * SIGKILL a tutti i processi figli ed al processo stesso. */
        pid = fork();
        if (pid < 0) {
            perror("Fork fallita.");
            kill(0, SIGKILL);
        } else if (pid == 0) {
            execl("./cracker", "cracker", argv[i], NULL);
            perror("Exec fallita.");
            exit(EXIT_FAILURE);
        }
        crackers[i-2] = pid;
        running++;
    }

    /* Attendi la terminazione dei rimanenti cracker. */ 
    while (running > 0) {
        success += wait_child(crackers, argv);
        running--;
    }
    printf("Crackati %d hash su %d!\n", success, argc-2);
    free(crackers);

    return EXIT_SUCCESS;
}

Aumentare il numero di processi non è necessariamente vantaggioso: se il numero di processi creati è superiore al numero di core della CPU presente nel sistema, i vari cracker dovranno condividere l’utilizzo della CPU e quindi non ottenete alcun miglioramento nei tempi di esecuzione rispetto all’uso di un numero minore di processi. Potete osservare questo comportamento nel grafico sottostante dove viene mostrato il tempo necessario per crackare i 64 hash presenti nel file hashes_medium.txt su un laptop con una CPU quad-core in relazione al numero di processi cracker utilizzati.

2 thoughts on “Esercitazione sulla gestione dei processi”

  1. Gentile docente,
    ho svolto in questo modo l’esercitazione da lei assegnata, facendo alcune prove il programma sembra funzionare. Volevo sapere se l’esercizio è svolto in modo corretto oppure se c’è qualche errore o miglioria da fare che io non riesco a vedere.
    In attesa di una risposta le porgo distinti saluti.

Leave a Reply

Your email address will not be published. Required fields are marked *