Skip to content

2019

Write once, read never

Commenti del 6 dicembre 2019 su un blog, in una discussione sui linguaggi di programmazione. Li ho riorganizzati in un unico testo.

Vero, non è il linguaggio quanto chi ci sviluppa.

Ho visto capolavori di stile scritti in Perl (cui è stato assegnato l'infame motto "write once, read never") ed ho visto geroglifici scritti in linguaggi che dovrebbero perlomeno facilitare la scrittura, es. Python.

Però non sono del tutto d'accordo che un linguaggio complesso sia vantaggioso. Da informatico mi piace risolvere problemi complessi in modi semplici. Risolvo problemi complicati perché odio le complicazioni.

Quindi se il linguaggio è complicato, rischi di attrarre gente che le complicazioni le amano! Lo dico perché nel mio ambito di lavoro vedo spesso dei framework complicati, ma lo sono perché sono stati progettati male e senza buon gusto estetico, non perché devono per forza essere così.

Questo il mio punto di vista ;)


C'è poi un problema più generale, che non dipende dal linguaggio: il copia-incolla.

Seguo (e quando ho tempo, partecipo attivamente) la community Stack Overflow, dove gli sviluppatori fanno domande ed altri sviluppatori rispondono. Ci sono dei veri e propri guru che partecipano, ma vi partecipano utenti di tutti i livelli (anche scarsi).

Copiare ed incollare una soluzione che poi finisce in produzione avviene regolarmente, il fatto è che non tutte le soluzioni sono "giuste": magari risolvono immediatamente un particolare problema ma ne introducono altri, sono obsolete, od hanno problemi gravi, anche di sicurezza. E non tutti vogliono approfondire.

Se ne parla ad esempio in questo articolo del blog di Stack Overflow (del novembre 2019):

https://stackoverflow.blog/2019/11/26/copying-code-from-stack-overflow-you-might-be-spreading-security-vulnerabilities/

ci si riferisce principalmente al C/C++ ma è utile per qualsiasi sviluppatore.

Daily coding problem #15: Random element from the stream

Problema:

Given a stream of elements too large to store in memory, pick a random element from the stream with uniform probability.

Scegliere un elemento casuale con probabilità uniforme da un array di dimensione n è semplice:

from random import randrange

foo = ['A', 'B', 'C', 'D']
n = len(foo)

print(foo[randrange(n)])

in realtà vi sarebbe anche il metodo random.choice() o lo stesso metodo della libreria secrets che garantisce sicurezza a livello crittografico.

Se lo stream però è troppo grande per stare in memoria, e non ne conosciamo la dimensione in anticipo, non possiamo applicare il metodo precedente ma dobbiamo utilizzare una diversa strategia. Necessariamente dovremo selezionare un elemento mentre stiamo leggendo lo stream, avendo come unica informazione il numero di elementi ricevuti fino a quel momento, ma non sapendo quanti ne mancano alla fine.

L'elemento corrente avrà probabilità $\(\frac{1}{n}\)$ di essere estratto, dove $\({n}\)$ è il numero di elementi letti dallo stream fino a quel momento. Potremmo selezionarlo in questo modo:

if random.randint(1,n) == n:
    chosen = current_element_from_stream

che cosa succede ad eventuali elementi letti in precedenza? Dobbiamo dimostrare come tutti gli elementi abbiano a loro volta la stessa probabilità $\(\frac{1}{n}\)$ di essere stati scelti.

L'elemento precedente lo abbiamo scelto con probabilità $\(\frac{1}{(n - 1)}\)$ il quale ha probabilità $\(\frac{(n - 1)}{n}\)$ di rimanere, da cui si ricava la sua probabilità che è $\(\frac{1}{(n-1)} \cdot \frac{(n-1)}{n} = \frac{1}{n}\)$.

Il codice completo può essere il seguente (dove lo stream è "simulato" da un array):

import random

def get_random_element(stream):
    chosen = None
    n = 0

    for i in range(len(stream)):
        n=n+1
        if random.randint(1,n) == n:
            chosen = stream[i]

    return chosen

stream = ['A', 'B', 'C', 'D']
print(get_random_element(stream))

Come funziona un buffer overflow

Commenti del 25 ottobre 2019 su un blog, in una discussione sulla sicurezza dei file audio e sui malware. Li ho riorganizzati in un unico testo.

Il buffer overflow è stato uno dei vettori di attacco più comuni, e non è che sia del tutto sparito. I vari sistemi operativi recenti offrono protezioni aggiuntive che, in combinazione con funzionalità hardware, rendono sempre più difficile la cosa. Ma vale la pena capire come funziona.

Un errore classico

Storicamente funzionava così:

  • un programmatore dichiarava un buffer di una certa dimensione, es. 80 caratteri
  • un attaccante malevolo passava in input al programma un parametro troppo lungo, es. 800 caratteri
  • il programma, causa bug, non applicava tutti i controlli necessari e copiava l'input da 800 caratteri sul buffer da 80
  • i caratteri eccedenti andavano a sovrascrivere aree di memoria non previste, che con un po' di fortuna potevano essere associate ad esempio ad altre variabili - ecco che con questo trucco potremmo andare a cambiare dei valori cui non dovremmo avere accesso, e quindi cambiare le logiche di esecuzione del programma
  • meglio ancora, le aree di memoria non previste potevano essere associate allo stack. Quando richiamo una funzione (call) viene inserita nello stack la posizione dell'istruzione che segue la call, in modo da ritornare lì (ret) una volta terminata la funzione. Ecco che modificando i valori inseriti nello stack, tra cui l'elenco delle istruzioni cui riprendere l'esecuzione terminata una funzione, possiamo dire ad un programma di eseguire le istruzioni di una qualsiasi area di memoria, magari proprio all'interno di questi 800 caratteri.

Ecco, quanto descritto è un metodo "classico" causato da un errore "classico": buffer troppo corto, mancanza di adeguati controlli.

Perché funziona

Devi ragionare in termini di linguaggio macchina, e devi pensare a come il tuo codice C o C++ è compilato e a come viene poi eseguito. L'attacco che ho descritto è - era? - molto comune, ed è stato usato con successo diverse volte: la maggior parte dei worm che si diffondevano in rete sfruttavano proprio questa tecnica.

L'attacco consiste nell'inviare una stringa molto più lunga di quanto il programma ha previsto, che contiene:

  1. il codice macchina malevolo da eseguire
  2. l'indirizzo del codice macchina al punto 1

In particolare, l'indirizzo al punto 2 oltre a dover essere calcolato precisamente, è indispensabile che sia perfettamente allineato, in modo che si sovrapponga alla struttura dati che contiene le "informazioni di controllo dell'esecuzione" del programma.

Il buffer può trovarsi dove si vuole, ma la sua posizione relativa potrebbe essere sempre la stessa, ecco perché il sistema funziona. Vari sistemi, come l'Address Space Layout Randomization, cercano appunto di rendere le posizioni relative non prevedibili, per cui questo tipo di attacco è molto più difficile (ma non impossibile).

Un crash non è la fine

In sostanza, è vero che la maggior parte delle volte un bug di questo tipo produce un errore di segmentation fault, ma inviando dei dati malvagi, preparati con grande accuratezza, è possibile far eseguire al programma qualche cosa di non previsto.

Non sempre questi bug sono sfruttabili (exploitabili): si potrebbe riuscire a mandare in crash il programma ma potrebbe non essere possibile controllarne l'esecuzione. Comunque un bel crash promette sempre bene.

Un errore di segmentation fault è un punto di partenza, da solo serve poco se non per mettere in piedi attacchi DoS (denial of service), ma significa che il programma ha qualche bug che potrebbe essere sfruttato per un attacco.

Le protezioni moderne

Questo metodo è sempre più difficile da applicare:

  • i compilatori potrebbero segnalare dei warning
  • gli strumenti di analisi del codice dovrebbero essere in grado di segnalare probabili bug di questo tipo
  • i sistemi operativi nonché i processori offrono protezioni aggiuntive, come l'ASLR, il DEP, lo stack canary

Ci sono comunque anche diverse strategie di attacco.

Nota sui file audio e sulle immagini

Un'ultima precisazione: la frase "non è possibile infettarsi semplicemente scaricando e ascoltando un file audio WAV" andrebbe esplicitata meglio.

Nemmeno con i file JPG, HTML, o Flash sarebbe teoricamente stato possibile infettarsi, nemmeno con le e-mail (sono solo dei banali testi!) o con le chiavette USB, eppure causa programmi vulnerabili è successo più di qualche volta.

Daily coding problem #28: Write an algorithm to justify text

Problem:

Write an algorithm to justify text. Given a sequence of words and an integer line length k, return a list of strings which represents each line, fully justified.

More specifically, you should have as many words as possible in each line. There should be at least one space between each word. Pad extra spaces when necessary so that each line has exactly length k. Spaces should be distributed as equally as possible, with the extra spaces, if any, distributed starting from the left.

If you can only fit one word on a line, then you should pad the right-hand side with spaces.

Each word is guaranteed not to be longer than k.

For example, given the list of words ["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"] and k = 16, you should return the following:

["the quick brown", # 1 extra space on the left

"fox jumps over", # 2 extra spaces distributed evenly

"the lazy dog"] # 4 extra spaces distributed evenly

This looks like a problem that can be quickly solved in a few lines of codes, but it would actually require a little more lines than expected. While the case of a word longer than the given integer k is not specifically covered in the problem description, I assume than such word has to be split into k sized chunks.

The logic is going to be like this:

  • I keep a buffer that holds all words that can fit into a single line. This buffer is empty at first.
  • From the given array I read each word, one by one. I eventually split each word into k-sized chunks.
  • Is the current word / word chunk going to fit into the current line? If there's still space for it I am going to add it to the current line buffer.
  • If the current word / chunk won't fit, I am going to output the current line (all words in the buffer, with proper space justification). The buffer will then be set to contain only the current word.
  • When I am finished reading all words, I am going to output the current line (unless the buffer is empty, which will happen only if the array of words is empty as well)

This is how I am going to call the function:

if __name__ == '__main__':
    print("\n".join(justify(["the", "quick", "brown", "fox",
        "jumps", "over", "the", "lazy", "dog"], 16)))

And this is how my justify function looks like:

def justify(words, k):
    res = []
    current_length = 0
    current_words = []

    for word in words:
        # read each word
        for i in range(0, len(word), k):
            # split each row into k-sized chuncks
            chunk = word[i:i+k]

            # try to add a new word to current_words / current_length
            if len(chunk) + current_length + len(current_words) <= k:
                # can fit
                current_words.append(chunk)
                current_length += len(chunk)
            else:
                # won't fit
                res.append(justify_line(current_words, current_length, k))
                current_words = [chunk]
                current_length = len(chunk)

    if current_words:
        res.append(justify_line(current_words, current_length, k))

    return res

The final part is handled by the justify_line function, that accept as input the array w of words that fits in the current line, the length l of the current words (not considering spaces), and the width of the like k.

(l is not strictly necessary, I have all elements to calculate it inside the function, but since it's alreay available I'll just pass it to the function)

def justify_line(w, l, k):
    if len(w)==1:
        return w[0].ljust(k)
    else:
        narrow_spaces, wider_words = divmod(k - l, len(w)-1)
        ns = " " * narrow_spaces
        ws = " " * (narrow_spaces + 1)

        return ns.join([ws.join(w[0:wider_words+1]), *w[wider_words+1:]])
  • If the current line contains only one word then I'll just return this word left-justified with spaces at the right (as from the problem specs).

  • If instead the line is composed by more words, we have to calculate:

  • the total number of spaces needed (k - l)
  • the minimum number of spaces between each word (total numer of spaces DIV number of words minus one)
  • the number of words that require an additional space (total number of spaces MOD number of words minus one)

For example if I call justify_line(['the', 'quick', 'brown'], 13, 16): - narrow_spaces will be set to 1 (one space between each word) - wider_words will be set to 1 (the first word needs one more extra space after it) - ws.join(w[0:wide_words+1]) will join the first and second words with wider spaces - ns.join() will then join the wider-spaced-words with all remaining words

To make things easier during developement, and to make sure that a little fix won't mess up everything, I wrote this testing unit:

import unittest
import re
from justify_text import justify

class TestStringMethods(unittest.TestCase):

    def test_given(self):
        self.assertEqual(
            justify(
                ["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"], 16),
                ["the  quick brown",
                 "fox  jumps  over",
                 "the   lazy   dog"])

    def test_lengths(self):
        for i in range(1,80):
            j = justify(["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"], i)
            lengths = list(map(lambda x: len(x), j))
            for n in range(0, len(lengths)):
                self.assertEqual(lengths[n], i)

    def test_decrease(self):
        for i in range(1,80):
            j = justify(["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"], i)

            spaces_split = list(map(lambda x: re.split('[^ ]+', x), j))
            for n in range(0, len(spaces_split)):
                self.assertEqual(spaces_split[n][0], '')
                for t in range(1, len(spaces_split[n])-2):
                    self.assertTrue(len(spaces_split[n][t])>=len(spaces_split[n][t+1]))

if __name__ == '__main__':
    unittest.main()
  • test_given: the result of the function has to match the result from the problem specification
  • test_lengths: all strings have to be of length k (k from 1 to 80)
  • test_decrease: all strings have to start with a word, not a space, and the number of spaces from left to right has not to increase