MokaByte Numero 21 - Luglio/Agosto 1998  


di
Lorenzo Bettini
  Agenti mobili in Java
(III parte)

La sicurezza

  Riprendiamo il nostro discorso sull'implementazione degli agenti mobili in Java, ed espandiamo il package con la gestione della sicurezza.


Sicurezza nel jdk 1.1

Ogni computer connesso alla rete potenzialmente può essere attaccato dall’esterno. Questo è ancora più vero nel caso delle applet, in cui del software viene scaricato dalla rete. Del resto il codice binario delle applet viene scaricato automaticamente quando si accede tramite browser ad una pagina web che le contiene, quindi non si ha nemmeno il tempo di decidere (a meno che non si imposti nelle opzioni del browser di non scaricare le applet Java).

Questi pericoli non si limitano alle applet, ma si riscontrano anche se si scrivono applicazioni distribuite che fanno uso di codice mobile [1] cioè scaricano a run time dalla rete del codice di cui non vi è traccia sul computer locale. Nei due precedenti articoli [2] e [3] (che vi consiglio di tenere alla mano) abbiamo visto come implementare agenti mobili in Java. Effettivamente quando un agente viene mandato in esecuzione sul server, può potenzialmente eseguire qualsiasi operazione (si ricordi che per le applicazioni non vengono applicate le restrizioni delle applet), anche cancellare file sul file system locale o eseguire system call.

Essendo Java un linguaggio molto adatto alle applicazioni distribuite, e permettendo il loading dinamico (cioè a run-time) delle classi, deve mettere a disposizione un meccanismo per gestire la sicurezza e proteggersi dal codice remoto.

In questo articolo vedremo come aggiungere dei meccanismi di sicurezza sull'AgentServer in modo da evitare che un agente ricevuto dalla rete danneggi il sistema locale.

Il modello SandBox

Il meccanismo fondamentale è costituito dal modello sandbox, un ambiente in cui il codice può compiere solo un numero limitato di operazioni che accedono a risorse di sistema come file e connessioni in rete. Secondo questo modello le applicazioni (codice locale, cioè trusted) non sono soggette a restrizioni, mentre le applet (codice scaricato dalla rete, quindi untrusted), essendo eseguite all’interno della sandbox, possono accedere solo ad un numero limitato di risorse.

Questo modello si basa fondamentalmente sull’idea che "prevenire è meglio che curare": di solito una volta che un virus si è diffuso nel sistema è difficile poterlo fermare e comunque il sistema non è più da ritenersi sicuro (anche per un sistema Unix, una volta penetrato da un hacker, sarebbe necessaria una nuova installazione). In questo modo tramite la sandbox si proibisce, fin dall’inizio, di compiere alcune azioni potenzialmente pericolose a codice non fidato.

La sicurezza riguarda diversi aspetti dell’architettura di Java: le varie parti dell’architettura daranno il proprio contributo garantendo che alcuni programmi non riescano a compiere azioni dannose. Queste parti sono:

  1. Il linguaggio Java di per sé e la Java Virtual Machine
  2. Il Class Loader
  3. Il Class Verifier
  4. Il Security Manager

In particolare due di questi componenti sono personalizzabili dal programmatore: il Class Loader ed il Security Manager.

Già a livello di sintassi Java, tramite alcune restrizioni, evita il verificarsi di certi eventi e proibisce la possibilità di compiere alcune azioni. Java è un linguaggio type-safe (anche se recentemente è stato dimostrato formalmente e con un contro esempio che il sistema di tipi di Java non è sound cioè corretto, almeno nella versione 1.1), ed il casting a run-time fra i vari tipi è controllato in modo che non venga permessa la conversione fra tipi incompatibili. (come ad esempio è possibile, con effetti abbastanza imprevedibili, in C++).

Ovviamente la sicurezza a livello di sintassi servirebbe a poco se non ci fossero controlli a livello di byte code: un cracker potrebbe scrivere direttamente in byte code e compiere azioni illecite. E’ necessario compiere un controllo del byte code prima che questo venga eseguito (si ricorda comunque che la JVM esegue un costante monitoraggio delle varie azioni di un programma Java). Il controllo prima dell’esecuzione si rende necessario per evitare che un programma vada in crash in modo inaspettato. Ogni JVM è dotata di un class file verifier [5], che assicura che la classe caricata (il suo .class file) abbia una determinata struttura interna, conforme allo standard di questi tipi di file. Se il class verifier rileva un problema o un errore all’interno del file, verrà lanciata un’eccezione. Questo controllo è necessario in quanto non si può sapere se il byte code provenga da un compilatore Java o direttamente da un cracker che lo ha scritto a mano.

Abbiamo già visto in [4] che tramite il class loader è possibile costruire name space multipli, ed un oggetto appartenente ad un cero name space non può accedere ad un oggetto appartenente ad un altro name space, quindi anche questa è una notevole forma di sicurezza.

In questo articolo invece vedremo come personalizzare un Security Manager in modo da permettere solo un numero limitato di operazioni.

Nel caso si utilizzino e si sviluppino solo applet non si avrà la necessità di personalizzare un Security Manager (anche perché poi non lo si potrà installare, pena in entrambi i casi una SecurityException), proprio per motivi di sicurezza: in effetti se un’applet riuscisse a istallare un proprio Class Loader o Security Manager, avrebbe la possibilità di caricare in modo personalizzato una certa classe pericolosa, o, nel caso del Security Manager, evitare alcuni controlli di sicurezza vitali.

Comunque anche in questo caso, capire come funziona la sicurezza in Java, e soprattutto questi componenti, può senz’altro essere utile per risolvere problemi che si riscontreranno nello sviluppo e nell’esecuzione di programmai Java.

Invece nel caso si sviluppino applicazioni che scaricano codice dalla rete (codice mobile) non solo si avrà la necessità di scrivere un proprio class loader (per poter appunto caricare in memoria il codice scaricato, non presente nel computer locale), ma sarebbe bene (anzi sarebbe quasi un obbligo) scrivere anche un Security Manager personalizzato che non permetta al codice ricevuto dalla rete di compiere azioni critiche e potenzialmente pericolose. Infatti mentre nel caso delle applet tutte queste azioni sono automaticamente proibite dalla politica di sicurezza di Java ed in particolare dai browser, nel caso delle applicazioni normali non si hanno limitazioni di sorta, ed è quindi possibile (teoricamente) compiere azioni che danneggino la macchina locale.

Il Security Manager

Si è visto che tramite il linguaggio ed il class verifier è possibile "scartare" codice strutturalmente non corretto e potenzialmente pericoloso; col class loader inoltre è possibile creare name space distinti, evitando così che gli oggetti, appartenenti a name space diversi possano interferire l’uno con l’altro. Tuttavia il sistema è ancora aperto ad azioni legali ma potenzialmente dannose, come l’accesso al file system o alle system call. Questo non riguarda le applet, che possono compiere solo un numero limitato di azioni, e fra queste non ci sono quelle appena citate, ma le applicazioni sulle quali non è attiva nessuna restrizione.

Il Security Manager permette di definire, e quindi di personalizzare, i limiti (nel senso di confini) della sand box. In questo modo si può definire le azioni che possono essere effettuate da certe classi o thread, e impedire che ne vengano compiute altre.

Per creare un Security Manager personalizzato [6] si deve derivare dalla classe SecurityManager, una classe astratta appartenente al package java.lang. Un Security Manager è quindi programmato direttamente in Java. Una volta installato dall’applicazione corrente, tale Security Manager effettuerà un monitoraggio continuo sulle azioni svolte (o meglio che sarebbero svolte) dai vari thread dell’applicazione: le API Java chiedono al Security Manager attivo il permesso di compiere certe azioni, potenzialmente pericolose, prima di eseguirle effettivamente. Per ogni azione di questo tipo esiste un metodo nella classe SecurityManager della forma checkXXX, dove XXX indica l’azione in questione, che viene richiamato prima di compiere tale azione. Ad esempio il metodo checkRead viene chiamato dalle API di Java prima di eseguire un’azione di lettura su un file, mentre checkWrite prima di eseguire un’azione di scrittura su un file.

L’implementazione di questi metodi stabilisce la politica di sicurezza che verrà applicata all’applicazione corrente; quindi i metodi checkXXX stabiliscono se il thread corrente può compiere l’azione descritta da XXX. Si tenga comunque presente che un Security Manager non riesce ad impedire l’allocazione di memoria e lo spawning di thread; questo vuol dire che non riesce a controllare i cosiddetti attacchi denial of service in cui si impedisce l’utilizzo del computer sommergendolo di richieste (esecuzione di processi e allocazione di tutta la memoria). In questi casi si dovrebbero utilizzare tecniche e controlli costruiti ad hoc.

Alla partenza un’applicazione non ha nessun Security Manager installato, ed è quindi aperta a qualsiasi tipo di azione, in quanto non viene applicata nessuna restrizione sulle azioni. Come già detto questo non è il caso delle applet: il browser installa automaticamente un Security Manager, che proibisce diverse azioni alle applet. Una volta installato il Security Manager rimarrà attivo per tutta la durata dell’applicazione e ovviamente, per motivi di sicurezza, non sarà possibile installare, durante il corso dell’applicazione un altro Security Manager (pena una SecurityException). Questo è consistente col fatto che un’applet non può installare un proprio Security Manager (altrimenti la sicurezza non sarebbe più garantita, in quanto l’applet potrebbe concedere a se stessa qualsiasi tipo di azione). Per installare un Security Manager basterà eseguire le seguenti istruzioni:

try {
   System.setSecurityManager( new mySecurityManager(...) ) ;
} catch ( SecurityException se ) {
   System.err.println( "Sec.Man. già installato!" ) ;
}

Tipicamente un metodo checkXXX ritorna semplicemente se l’azione viene permessa, mentre lancia una SecurityException in caso contrario; ad esempio un’implementazione di un metodo checkRead potrebbe essere la seguente:

public void checkRead( String filename ) {
  if ( azione non permessa )
     throw new SecurityException( "Lettura non permessa" ) ;
}

Quindi quando viene invocato un metodo che utilizza un’API di Java, viene controllato se è installato un SecurityManager, ed in caso positivo viene richiamato il metodo check opportuno; ad esempio un’idea di quello che può compiere un’API, prima di uscire dall’applicazione corrente è:

...
SecurityManager secMan = System.getSecurityManager() ;
if ( secMan != null ) {
   secMan.checkExit( status ) ;
}
esegue le azioni per terminare l’applicazione corrente

Nella seguente tabella sono illustrati i vari metodi presenti nella classe SecurityManager relativi alle varie azioni che si possono compiere su determinate risorse di sistema (si rimanda alla documentazione in linea per una descrizione completa dei vari metodi):

sockets          checkAccept(String host, int port)
                 checkConnect(String host, int port)
                 checkConnect(String host, int port, Object executionContext)
                 checkListen(int port)

threads          checkAccess(Thread thread)
                 checkAccess(ThreadGroup threadgroup)

class loader     checkCreateClassLoader()

file system      checkDelete(String filename)
                 checkLink(String library)
                 checkRead(FileDescriptor filedescriptor)
                 checkRead(String filename)
                 checkRead(String filename, Object executionContext)
                 checkWrite(FileDescriptor filedescriptor)
                 checkWrite(String filename)

system commands  checkExec(String command)

interpreter      checkExit(int status)

package          checkPackageAccess(String packageName)
                 checkPackageDefinition(String packageName)

properties       checkPropertiesAccess()
                 checkPropertyAccess(String key)
                 checkPropertyAccess(String key, String def)

networking       checkSetFactory()

windows          checkTopLevelWindow(Object window)

Ad esempio se si volesse proibire ad una certa classe (e alle sue derivate) la possibilità di accedere in scrittura al file system basterà implementare

public void checkWrite(FileDescriptor fd) {
	if (Thread.currentThread() instance of NomeClasse )
		throw new SecurityException();
}

Oppure se lo si vuole proibire alle classi caricate da un class loader personalizzato (ad esempio che sono state scaricate dalla rete):

public void checkWrite(FileDescriptor fd) {
	if (Thread.currentThread().getClass().getClassLoader() 
			instanceof NetworkClassLoader)
		throw new SecurityException();
}

La sicurezza nel package Agent

Vediamo adesso cosa dobbiamo aggiungere al nostro package Agent in modo da aggiungere la sicurezza al server.

La classe AgentSecurityManager

Questa classe deriva direttamente dalla classe SecurityManager ed implementa alcuni metodi chieckXXX in modo che un agente remoto non riesca ad eseguire operazioni del tipo:

Vediamo l'implementazione di uno di questi metodi check di questa classe:

  public void checkWrite(String file) {
   	if ( notTrustedProcess() )
      		throw new SecurityException();
  }

Dove indicativamente il metodo notTrustedProcess() è così implementato:

  protected boolean notTrustedProcess() {
  	if ( Thread.currentThread().getClass().getClassLoader() != null )
      		return true ;
  	return false ;
  }

Quindi per capire se si tratta di un processo (agente) ricevuto dalla rete, basterà controllare se è stato caricato con un class loader diverso da quello primordiale. Si è detto indicativamente, perchè in realta sono altri i controlli da effettuare.

Effettivamente è stato necessario modificare alcune classi del package (questa volta non si tratta di una svista, è che inizialmente non pensavo di aggiungere un Security Manager al package). Infatti così come è adesso il pacchetto il controllo sopra non avrebbe funzionato correttamente: il processo (il thread) che esegue quel metodo è di classe AgentHandler, la quale classe è caricata col class loader primordiale. Allora si dovrà modificare la classe DefaultAgentLoader in modo che estenda la classe thread (in modo che quando si richiama start dalla classe AgentHandler effettivamente si esegua lo spawn di un nuovo thread).

public interface AgentLoader {
  public void start() ;
  public void run() ;
  public void setServer( AgentServer server ) ;
  public void setInputStream( InputStream istream ) ;
  public void setOutputStream( OutputStream ostream ) ;
}
...
public class DefaultAgentLoader extends Thread implements AgentLoader {

Ovviamente si dovrà anche cambiare il metodo notTrustedProcess in modo che tratti in modo particolare questa classe, che è sì caricata con un class loader personalizzato, ma che comunque deve poter effettuare alcune operazioni. In effetti, altrimenti, si sarebbero ottenuti errori nel recupero dei byte della classe dell'agente: non proprio una SecurityException, ma un errore nel formato della classe in lettura. Non ho compreso molto bene questo errore, che comunque si presenta solo se è attivo un Security Manager, quindi, probabilmente, questa lettura provoca un errore di sicurezza, con conseguente fallimento del metodo.

Del resto quando viene mandato in esecuzione un agente, è il thread di classe AgentLoader che esegue le operazioni, quindi ancora una volta non si avrebbe la possibilità di scoprire se è in esecuzione del codice dell'agente oppure sono in esecuzione le azione dell'AgentLoader. Anche in questo caso la soluzione è quella di far partire l'agente (richiamando il metodo onArrival) da un thread diverso (AgentExecutor); ecco quindi come viene modificata la classe DefaultAgentLoader (dalla quale tra l'altro deriva anche AgentLoaderEx, che quindi non necessiterà di modifiche):

public class DefaultAgentLoader extends Thread implements AgentLoader {
  ...
  
  protected void startAgent( AgentPacket pack ) 
      throws ClassNotFoundException, IOException {
    ByteArrayInputStream byteIStream = new ByteArrayInputStream( pack.AgentBytes ) ;
    ObjectInputStream objIStream = new ObjectInputStream( byteIStream ) ;
    // questo provocherà il caricamento della classe dell'agente
    Agent agent = (Agent)objIStream.readObject() ;
    (new AgentExecutor( agent )).start() ;
  }
}

public class AgentExecutor extends Thread {
  Agent agent ;

  public AgentExecutor( Agent agent ) {
    this.agent = agent ;
  }

  public void run() {
    System.out.println( "Esecuzione agente : " + agent.AgentName() ) ;
    try {
      agent.onArrival() ;
    } catch ( AgentException ae ) {
      System.err.println( "Eccezione AgentException non gestita" ) ;
      ae.printStackTrace() ;
    }
  }
}

A questo punto il metodo all'interno del Security Manager sarà:

  protected boolean notTrustedProcess() {
    Thread thread = Thread.currentThread() ;
    if ( thread instanceof Agent.AgentLoader )
      return false ;
    if ( thread instanceof Agent.AgentExecutor ||
        thread.getClass().getClassLoader() != null )
      return true ;
    return false ;
  }

Il controllo thread.getClass().getClassLoader() != null ) servirà solo per intercettare eventuali nuovi thread mandati in esecuzione dall'agente.

Sono state introdotte nel pacchetto la classe AgentServerSecure e la classe AgentServerExSecure che installano il Security Manager; si è scelto di derivare queste classi dalle precedenti e ridefinire il metodo main, ma si sarebbe potuto ottenere lo stesso risultato modificando le classi precedenti in modo da accettare un paramentro a linea di comando per settare o meno il Security Manager.

public class AgentServerExSecure extends AgentServerEx {
  ...
  protected void setSecurityManager() {
    try {
      System.setSecurityManager( new AgentSecurityManager() ) ;
    } catch ( SecurityException se ) {
      System.err.println( "Sec.Man. già installato!" ) ;
    }
  }

  public static void main( String args[] ) throws IOException {
    if ( args.length > 3 )
      throw new IOException(
        "Syntax : AgentServerExSecure {<port> <messagesOn(1)> <loaderName>}" ) ;
    int port = AgentServer.defaultPort ;
    if ( args.length > 0 )
      port = Integer.parseInt( args[ 0 ] ) ;

    System.out.println( "Starting AgentServer on port " + port ) ;

    AgentServerExSecure agentServer ;
    if ( args.length > 2 )
      agentServer = new AgentServerExSecure( port, args[2] ) ;
    else
      agentServer = new AgentServerExSecure( port ) ;

    if ( args.length > 1 && args[1] == "1" )
      agentServer.setMessages( true ) ;

    agentServer.setSecurityManager() ;
    System.out.println( "Security Manager installato" ) ;

    agentServer.start() ;
  }
}

Un esempio

Ecco come sempre un esempio di agente per testare il Security Manager (per eseguire correttamente l'esempio si seguano le istruzioni per il settaggio della variabile CLASSPATH incluse nel precedente articolo [3]):

public class TestAgentExSecure extends AgentEx {
  protected String host ;
  protected int port ;

  public TestAgentExSecure( String s, int p ) {
    super( "MALIGNO" ) ;
    host = s ;
    port = p ;
  }

  protected void execute() throws AgentException {
    setMessages( true ) ; // per debug
    System.out.println( "Salve io l'agente " + AgentName() ) ;
    System.out.println( "e cercherò di STENDERE il server =:->" ) ;
    System.out.println( "eh eh eh... =:->" ) ;
    System.out.println( "Adesso migro su " + host + ":" + port ) ;
    migrate( host, port ) ;
    System.out.println( "Agente migrato" ) ;
  }

  public void onArrival() throws AgentException {
    System.out.println( "Salve, sono l'agente " + AgentName() +
      " appena arrivato..." ) ;
    System.out.println( "E adesso STENDERO' il server =:->" ) ;
    System.out.println( "eh eh eh..." ) ;
    try {
      System.exit(1) ;
    } catch ( SecurityException se ) {
      System.out.println( "Ach... mi hanno beccato ! =:-(" ) ;
    }
  }

  public static void main( String args[] ) {
    ...
  }
}

Come vedete quando l'agente arriverà sul server cercherà di terminarlo con una banalissima System.exit(1); se provate a spedirlo su un semplice AgentServerEx noterete che l'agente riuscirà tranquillamente nel suo intento! Lo stesso succederebbe se l'agente provasse a scrivere sul disco o cancellare file! (ho preferito non effettuare questa dimostrazione :-); se volete potete provare, ma fatelo con un file in lettura, per essere sicuri di non modificare o cancellare per sbaglio un file importante). Provate adesso invece a spedirlo su un AgentServerExSecure, e l'agente verrà scoperto e si "beccherà" una bella SecurityException: la giustizia ha trionfato :-)

IMPORTANTE:

Rieseguendo il codice, ed anche codice vecchio che comunque utilizzava un class loader personalizzato (come quello nell'articolo sul NetworkClassLoader [7]) ho notato (ed ho avuto la conferma anche da altri programmatori Java, tra i quali, appunto anche Donato Cappetta) che il metodo getSystemResourceAsStream(className) di classe ClassLoader restituisce sempre null (sembra che non sia implementato); non ho avuto modo di constatare, visto che non ho più alla mano versioni precedenti alla 1.1.5, che si tratta di un bug di quest'ultima versione, in quanto prima sono, abbastanza, sicuro che funzionasse. Quindi ho semplicemente sostituito nella classe ClassBytesLoader la chiamata di tale metodo con la ricerca manuale del file .class all'interno di tutte le directory sapecificate nel CLASSPATH, proprio come avevo fatto in [4], quindi il metodo loadClassBytes diventa:

public class ClassBytesLoader {
  // può essere chiamata anche da altre classi
  public static byte[] loadClassBytes( String className ) 
    throws IOException {				
		  int size ;
      byte[] classBytes ;
      InputStream is ;
      String fileSeparator = System.getProperty( "file.separator" ) ;
      className = className.replace('.', fileSeparator.charAt(0));
      className = className + ".class";

      String classpath = System.getProperty( "java.class.path" ) ;
      StringTokenizer st = new StringTokenizer(
          classpath, System.getProperty("path.separator") ) ;
      File classFile = null ;
      File dir = null ;
      while( st.hasMoreTokens() ) {
          dir = new File( st.nextToken() ) ;
          System.out.println( "directory : " + dir ) ;
          classFile = new File( dir, className ) ;
          if ( classFile.exists() )
            break ;
      }

      FileInputStream fi = new FileInputStream( classFile ) ;
      classBytes = new byte[ fi.available() ] ;
      fi.read( classBytes ) ;

      return classBytes;
  }
}

Conclusioni

Come già detto nei precedenti articoli il pacchetto presentato è fondamentalmente per scopi didattici, tuttavia si presta molto bene per essere utilizzato per semplici applicazioni con codice mobile, ed inoltre è anche semplice modificare (e/o estendere) il pacchetto.

La mia intenzione sarebbe quella di riscrivere il pacchetto in modo da ottenerne uno molto più funzionante ed utilizzabile anche per applicazioni più complesse. Se qualcuno è interessato a partecipare al progetto, si faccia vivo :-)

A presto

Lorenzo Bettini

Il materiale qui presentato si basa su una parte del framework realizzato in [8].

Nota di Agosto 2001. I sorgenti sono stati modificati in modo che il pacchetto e gli esempi funzionino anche con Java 1.2. 

Sorgenti (modificati in Agosto 2001)

ag3src.zip

Bibliografia

[1] Lorenzo Bettini, La programmazione distribuita in Java, MokaByte Novembre 1997
[2] Lorenzo Bettini, Agenti mobili in Java (I parte), MokaByte Maggio 1998
[3] Lorenzo Bettini, Agenti mobili in Java (II parte), MokaByte Giugno 1998
[4] Lorenzo Bettini, Il class loader, MokaByte Marzo 1998
[5] B.Venners, Security and the class verifier, JavaWorld ( http://www.javaworld.com ) Ottobre 1997.
[6] Providing Your Own Security Manager, dal Tutorial di Java ( http://java.sun.com/docs/books/tutorial/ )
[7] Donato Cappetta, Lorenzo Bettini, Un NetworkClassLoader in Java, MokaByte Aprile 1998
[8] Lorenzo Bettini, Progetto e realizzazione di un linguaggio di programmazione per codice mobile, tesi di Laurea in Scienze dell'Informazione, Aprile 1998, http://rap.dsi.unifi.it/xklaim .