ネットワークとセキュリティ
前回の授業では、バックエンドのプログラムの問題によるセキュリティ問題を説明した。今回は、ネットワークの物理的な接続方法などの話を中心にセキュリティについて説明を行う。
ネットワークからの攻撃とFireWall
脆弱性とバッファオーバーフロー
プログラムには、何らかのバグが潜んでいる可能性があり、悪用すると悪意のプログラムの実行や、情報の漏えい、システム異常を発生させサービスができなくするなどの脆弱性があって、悪意のある利用者から攻撃をうける可能性がある。
例えば、下記のようなC言語のプログラムは、配列をはみ出るようなデータを与えることで、関数の戻り番地を破壊させ、はみ出た部分に送り込んだ悪意のプログラムを実行させることが可能となる。このような入力用のデータ領域(バッファ)をはみ出させる攻撃はバッファオーバーフローと呼ばれる。
なお、最近のC言語のライブラリでは、上記のようなバッファオーバーフロー攻撃が一般的であることから、ASLR(Address Space Layout Randomization)により、スタック領域などの位置をランダム化することで、バッファオーバーフローによる攻撃が失敗するようになっている。
ルータとFireWall
外部にサービスを提供するようなシステムで、何らかの脆弱性のあるプログラムが使われていると、外部からのネットワーク接続で悪意のあるプログラム(マルウェア)を実行させられてしまうかもしれない。
このため、コンピュータでは不必要なプログラム(ネットワークサービス)は、起動されないようにする必要がある。もしくは、そのサービスは外部から利用できないように、途中のルータで FireWall(防火壁) を設置する。
FireWall では、(1)攻撃の可能性のあるIPアドレスからの接続を拒否、(2)外部に公開していないネットワークサービスのポート番号の接続を拒否といった方法をとる(拒否リスト方式)。もっと厳しい対策であれば、(3)特定のIPアドレスの機器からのみ接続を許可、(4)許可されているネットワークサービスのポート番号だけからだけ許可する方式をとる(許可リスト方式)
外部に公開する必要のないサービスがFireWallなどで正しく保護されていないと、攻撃をうける可能性がある。
ネットワーク接続のための装置
ルータやFireWallなどの仕組みをもう少し理解するために、組織内でネットワークを接続するための機器とその機能について改めて確認する。
ルータとは
元々の有線LANでは、1本のケーブルを時分割多重で共有しながら通信を行う。このため、瞬間的にはとある機器がネットワークを使用している時は、他の機器はデータ通信ができなくなる。この1本の線を大量の機器で使おうとすると、機器が使えるタイミングは減ってしまう。そこで、1本の線に直接接続する機器を分割したサブネットに分けて、必要な時だけ隣接するサブネットにパケットを中継するルータ or ブリッジが重要となる。
ルータは、隣接するサブネットのネットワーク番号(IPアドレスとサブネットマスク)を確認し、パケットを流す先を決定する。このネットワーク番号(IPアドレスとサブネットマスクの論理積)と中継を依頼するゲートウェイ(転送先)の一覧をルーティングテーブルと呼ぶ。
組織内のルータであれば、ネットワークの構造に合わせてあらかじめルーティングテーブルを定義しておく(静的ルーティング)。組織と組織を接続するようなルータは、自分に送ってほしいネットワーク番号の情報を相互に交換している(動的ルーティング)。
ブリッジとHUB
スイッチングHUB
機器を接続するための古いHUB(ダムHUB)では、通信中は他の機器の通信ができず効率が悪い。最近のHUBでは、通信する相手に応じて、内部のネットワークケーブルをスイッチのように接続・分離することができるスイッチングHUBを用いる。通信相手の識別には、一般的にMACアドレスが用いられる。(レイヤ2でのスイッチングHUB)
家庭用のスイッチングHUBは、特に細かい設定などは不要で管理機能が無いものは、アン マネージド スイッチングHUBと呼ばれる。
L2スイッチとL3スイッチ
サブネットに分割し、それぞれに異なるネットワーク番号を割り振り、中継するルータで FireWall を機能させることで、セキュリティを高めることが行われる。しかし、性能の高いスイッチングHUBは高価でもあり、1つのHUBに異なるネットワークを接続する必要がでてくることもある。この場合、IPアドレスを異なるネットワークの番号を偽装されると、データが盗み見られるかもしれない。
こういった相互に分離すべきネットワークであっても、柔軟なネットワーク構成とするためには、VLAN機能を持った L2スイッチ(レイヤ2スイッチングHUB) が使われる。タグVLAN機能付きのL2スイッチでは、特定のポートにVLANのタグ番号を割り当て、ポートに入る時にパケットに VLAN のタグ情報を付加し、そのパケットは同じ VLAN のタグをもつポートからしかデータを取り出せない。
L2スイッチ(レイヤ2スイッチ)は、機器のMACアドレスやパケットに付けられたVLANのタグ番号の情報(レイヤ2=データリンク層)でパケットの流れを制御している(下記OSI参照モデルの表を参照)。最近では、許可されていない機器がネットワークに侵入する不正侵入を防ぐために、登録されていないMACアドレスのパケットを通さないといった機能がある。
OSI参照モデルとレイヤ 第7層 アプリケーション層 アプリケーションの種類の規定 第6層 プレゼンテーション層 データフォーマットの交換 第5層 セッション層 コネクションの確立や切断などの管理 第4層 トランスポート層 パケットの分割合成や再送といった管理(TCP) 第3層 ネットワーク層 隣接するネットワーク間の通信(IPアドレス) 第2層 データリンク層 直接接続された機器間の通信(MACアドレス) 第1層 物理層 物理的な接続方法(コネクタや電圧など)
スイッチングHUBの中には、レイヤ3(IPアドレス)の情報でパケットの流れを制御するものもある。こういったスイッチは、L3スイッチ(レイヤ3スイッチ)と呼ばれるが、機能的にはルータと同じである。
一般的には、LANとWANを接続するための機器はルータ、LAN内部のネットワークを分離するためのものはL3スイッチと呼ぶ。
インターネットと接続するルータの機能
ネットワーク通信のIPアドレスとポート番号
クライアントの機器と通信相手との通信では、通信相手のIPアドレスとポート番号を指定してパケットを送出するが、処理結果を送信元に送り返すために、送信元のIPアドレスとポート番号が付加されている。送信元ではポート番号は、通信でよく使われる0~1023までのポート番号(ウェルノウンポート)以外で、1024~65535のポート番号(エフェメラルポート)の中から使われていないものをランダムに選んで使う。
送信相手に届いたパケットの返信データには、送信元と送信相手のIPアドレスとポート番号を入れ替えたものを割り当てることで、送信元にパケットが戻ってくる。
- DIP = 送信先IPアドレス、DP = 送信先ポート番号
- SIP = 送信元IPアドレス、SP = 送信元ポート番号
NAT(Network Address Translation)
現在広く使われているIPv4アドレス(32bit)では、40億台の機器間の通信しかできない。このため、組織内だけで使われるIPアドレス(プライベートIPアドレス)を使い、インターネットではグローバルIPアドレスを使う。
プライベートIPアドレス クラスA/8 10.0.0.0~10.255.255.255 大規模組織向け クラスB/12 172.16.0.0~172.31.255.255 中規模組織向け クラスC/16 192.168.0.0~192.168.255.255 家庭用ルータ向け
組織内のLANからインターネット(WAN)に接続する際には、プライベートアドレスをグローバルアドレスに変換するNAT(Network Address Translation)の機能が使われる。
NATの問題点
しかし、インターネットの内側で異なる機器で同じポート番号が割り振られると、戻ってきたパケットをどちらの機器に送ればいいのか区別できなくなる。
NAPT(Netowrk Address and Port Translation)
そこで、最近のNATでは、IPアドレスの変換だけでなくポート番号の付け替えも行われる。この機能は正式には NAPT(Network Address and Port Translation) と呼ぶが、単に NAT と呼ぶことも多い。Linuxでは、NAPTの実装をIPマスカレードと呼ぶ。
FireWall と DMZ
組織内で外部に公開しているサーバがある場合は、以下の図のような構成にするかもしれない。しかし、このようなネットワーク構成では、FireWallの内側の公開サーバが攻撃されて、踏み台となった場合、組織内のPCが簡単に攻撃をうけてしまう。
そこで、外部からの接続を行う DMZ(De-Militarized Zone 非武装地帯) を設け、外部公開用の公開サーバは DMZ 内に設置する。外部とつながる FireWall では、外部からのパケットは DMZ に流れるように構成する。DMZ 内のサーバが踏み台になった場合を想定し、組織内のルータでは DMZ のサーバと組織内PCは通信できないように FireWall を2重に設置する。
配列に要素を追加
データが登録済みかどうかを判定する処理を作るために、登録された値を配列に次々と値を追加保存する場合、どのようにプログラムを記述するだろうか?
配列にデータを追加
次々と与えられた値を保存していくのであれば、Java であれば下記のようなコードが一般的であろう。
でも、ArrayList とはどのようにデータを覚えているのだろうか? なぜ 宣言は ArrayList<Integer> array であって ArrayList<int> array で宣言するとエラーが出るのであろうか?
import java.util.*; public class Main { public static void main(String[] args) throws Exception { // ArrayList は連続アドレス空間に保存してくれる可変長配列 // ランダムアクセスをする場合に向いている ArrayList<Integer> array = new ArrayList<Integer>() ; array.add( 11 ) ; array.add( 2 ) ; array.add( 333 ) ; for( Integer i : array ) { System.out.println( i ) ; } } }
このような ArrayList のようなデータ構造の仕組みを考えるために、最も単純な配列でプログラムを作ってみる。
末尾に追加
import java.util.*; public class Main { static int[] array = new int[ 10 ] ; static int size = 0 ; public static void add( int x ) { array[ size ] = x ; size++ ; } public static void main(String[] args) throws Exception { add( 11 ) ; add( 2 ) ; add( 333 ) ; for( int i = 0 ; i < size ; i++ ) System.out.println( array[i] ) ; } }
同じ処理をC言語で書いてみる。
#include <stdio.h> int array[ 10 ] ; int size = 0 ; void add( int x ) { // if ( size < array.length ) ... の判定が必要かも array[ size ] = x ; size++ ; } int main() { add( 11 ) ; add( 2 ) ; add( 333 ) ; for( int i = 0 ; i < size ; i++ ) printf( "%d\n" , array[ i ] ) ; return 0 ; }
しかし、このプログラムでは、最初に宣言した要素数10個を越えてデータを保存できないし、配列溢れさせないためには要素数の上限チェックも必要となるだろう。
昇順に並べながら途中に要素を追加
前述のプログラムでは、配列の末尾の場所を size で覚えておき、末尾にデータを追加していた。でも、配列に保存されている値の中から目的の値が含まれているか検索したいのであれば、配列に要素を昇順に保存しておいて2分探索法を使うのが一般的であろう。では、前述のプログラムを昇順で保存するにはどうすべきか?
最も簡単な方法で書くのであれば、下記のようなコードになるかもしれない。
public static void add( int x ) { int i ; for( i = 0 ; i < size ; i++ ) { // ここは2分探索で書けば O( log N ) にできるかも if ( array[ i ] > x ) break ; } // for( int j = i ; j < size ; j++ ) // 途中に挿入は、コレじゃダメ? // array[ j + 1 ] = array[ j ] ; for( int j = size - 1 ; j >= i ; j-- ) // 途中にデータを入れるために要素を1つ後ろに移動 array[ j + 1 ] = array[ j ] ; array[ i ] = x ; size++ ; }
void add( int x ) { int i ; for( i = 0 ; i < size ; i++ ) { if ( array[ i ] > x ) break ; } // for( int j = i ; j < size ; j++ ) // array[ j + 1 ] = array[ j ] ; for( int j = size - 1 ; j >= i ; j-- ) array[ j + 1 ] = array[ j ] ; array[ i ] = x ; size++ ; }
このプログラムでは、for( i … ) の処理でデータを挿入すべき場所を見つけ、for( int j … ) の繰り返しでデータを1つ後ろにずらしてから要素を加えている。
for( i … ) の処理は、このプログラムでは O( N ) となっているが、2分探索法を用いれば O( log N ) に改善ができるかもしれない。しかし、for( int j… ) の処理は、データを1つ後ろにずらす必要があるため O( N ) の処理が必要となる。
ここで、途中にデータを追加する処理の効率を改善することを考える。
リスト構造の導入
以下のデータ構造では、配列にデータと次のデータの場所を覚えることで、一見デタラメな順序に保存されているようにみえるが、next[] に次の値の保存されている場所が入っている。
import java.util.*; public class Main { // 0 1 2 3 4 5 static int[] data = new int[] { 11 , 55 , 22 , 44 , 33 , 0 , 0 , 0 , 0 , 0 } ; static int[] next = new int[] { 2 , -1 , 4 , 1 , 3 , 0 , 0 , 0 , 0 , 0 } ; static int size = 5 ; static int top = 0 ; static void insert( int n , int x ) { data[ size ] = x ; next[ size ] = next[ n ] ; next[ n ] = size ; size++ ; } public static void main(String[] args) throws Exception { for( int idx = top ; idx >= 0 ; idx = next[ idx ] ) System.out.println( data[ idx ] ) ; insert( 2 , 25 ) ; for( int idx = top ; idx >= 0 ; idx = next[ idx ] ) System.out.println( data[ idx ] ) ; } }
#include <stdio.h> int data[ 10 ] = { 11 , 55 , 22 , 44 , 33 , 0 , 0 , 0 , 0 , 0 } ; int next[ 10 ] = { 2 , -1 , 4 , 1 , 3 , 0 , 0 , 0 , 0 , 0 } ; int size = 5 ; int top = 0 ; void insert( int n , int x ) { data[ size ] = x ; next[ size ] = next[ n ] ; next[ n ] = size ; size++ ; } int main() { for( int idx = top ; idx >= 0 ; idx = next[ idx ] ) printf( "%d\n" , data[ idx ] ) ; insert( 2 , 25 ) ; for( int idx = top ; idx >= 0 ; idx = next[ idx ] ) printf( "%d\n" , data[ idx ] ) ; return 0 ; }
このようなデータ構造であれば、データ自体は末尾に保存しているが、次の値が入っている場所を修正することで途中にデータを挿入することができる。この方法であれば、途中にデータを入れる場合でもデータを後ろにずらすような処理が不要であり、O(1)で途中にデータを挿入できる。
このプログラムでは、配列の当初の長さを超えてデータを格納することはできない。
リスト構造 ListNode
前述の data と next で次々とデータを続けて保存するために、リスト構造(連結リスト)を定義する。
import java.util.*; class ListNode { int data ; ListNode next ; ListNode( int d , ListNode nx ) { this.data = d ; this.next = nx ; } } ; public class Main { public static void main(String[] args) throws Exception { ListNode top = new ListNode( 11 , new ListNode( 22 , new ListNode( 33 , null ) ) ) ; for( ListNode p = top ; p != null ; p = p.next ) System.out.println( p.data ) ; top.next = new ListNode( 15 , top.next ) ; for( ListNode p = top ; p != null ; p = p.next ) System.out.println( p.data ) ; } }
#include <stdio.h> #include <stdlib.h> struct ListNode { int data ; ListNode* next ; } ; ListNode* newListNode( int d , ListNode* nx ) { ListNode* _this = new ListNode() ; if ( _this != NULL ) { _this->data = d ; _this->next = nx ; } return _this ; } int main() { ListNode* top = newListNode( 11 , newListNode( 22 , newListNode( 33 , NULL ) ) ) ; for( ListNode* p = top ; p != NULL ; p = p->next ) printf( "%d\n" , p->data ) ; top->next = newListNode( 15 , top->next ) ; for( ListNode* p = top ; p != NULL ; p = p->next ) printf( "%d\n" , p->data ) ; return 0 ; }
Javaのジェネリクス
Javaのジェネリクス(C++のテンプレート)を使って書いてみた。ジェネリクスは、クラスやメソッドにおいて、特定の型を指定することなく動作するコードを記述することができる機能。これにより、型安全性を保ちながら、コードの再利用性と柔軟性を向上させることがでる。
import java.util.*; class ListNode<T> { T data ; ListNode<T> next ; ListNode( T d , ListNode<T> n ) { this.data = d ; this.next = n ; } } ; public class Main { public static void main(String[] args) throws Exception { // var 宣言は型推論で、右辺のデータ型を自動的に選択してくれる。 // itop は整数型のリスト var itop = new ListNode<Integer>( 11 , new ListNode<Integer>( 22 , new ListNode<Integer>( 33 , null ) ) ) ; // new List<int>( 11 , ... ) と書くと、<>の中は reference しか使えないと言われる。 for( var p = itop ; p != null ; p = p.next ) System.out.println( p.data ) ; // stop は文字列型のリスト var stop = new ListNode<String>( "aa" , new ListNode<String>( "bb" , new ListNode<String>( "cc" , null ) ) ) ; for( var p = stop ; p != null ; p = p.next ) System.out.println( p.data ) ; } }前述のプログラムをJavaのジェネリッククラスで記述
import java.util.*; public class Main { public static void main(String[] args) throws Exception { // LinkedList は上記のリスト構造で保存される。 // 途中に要素の追加削除を行ったり、シーケンシャルアクセスに向いたデータ構造 var top = new LinkedList<Integer>() ; top.add( 11 ) ; top.add( 22 ) ; top.add( 33 ) ; for( int i : top ) // 11 22 33 System.out.println( i ) ; top.add( 1 , 15 ) ; for( int i : top ) // 11 15 22 33 System.out.println( i ) ; } }
クラスの宣言とコンストラクタ・メソッド
import java.util.*; // クラス宣言 class Person { // データ構造 String name ; int age ; // コンストラクタ(データ構造を初期化する関数) Person( String n , int x ) { this.name = n ; // this は対象となるデータそのものを指す this.age = x ; // 対象が明言されていれば、this は省略可能 } // データを扱うメソッド void print() { // データを表示 System.out.println( this.name + "," + this.age ) ; } boolean sameAge( Person x ) { // 同じ年齢か判断するメソッド return this.age == x.age ; } } ; public class Main { public static void main(String[] args) throws Exception { Person tsaitoh = new Person( "Tohru Saitoh" , 59 ) ; Person tomoko = new Person( "Tomoko Saitoh" , 48 ) ; tsaitoh.print() ; // Tohru Saitoh, 59 tomoko.print() ; // Tomoko Saitoh,48 if ( tsaitoh.sameAge( tomoko ) ) { // sameAge( Person x ) では、 // this = tsaitoh , x = tomoko となって呼び出される System.out.println( "同じ年齢ですよ" ) ; } Person[] family = new Person[ 2 ] ; family[0] = tsaitoh ; family[1] = tomoko ; for( int i = 0 ; i < 2 ; i++ ) family[ i ].print() ; } }こ
このプログラムのデータ構造は下記のような状態。