Golang: Websocket masking과 cache poisoning
Photo by Christopher Robin Ebbinghaus on Unsplash
Websocket protocol을 보면 mask 여부를 표시하는 flag bit와 masking 적용시 사용할 maksing-key값이 들어가있다. 찾아보니 cache poisoning을 방지해준다 한다. cache poisoning이 뭘까?
cache poisoning이란 - 초간단 버전
cache poisoning까지 실제로 해보고, websocket message를 masking/non-masking 했을때의 차이까지 들여다보고 싶어졌지만 있었지만 토끼굴로 들어가면 안된다 싶어 최소한의 이해만 하고 넘어가도록 한다.
* 토끼굴(rabbit hole): a situation in which you become so interested in a subject or an activity that you cannot stop trying to find out about it or doing it
정상적인 상황
User가 website와 HTTP통신을 한다면 중간에 infrastructure 또는 proxy server를 거치는 경우가 많은데, 만약 같은 request가 많다면 매번 website까지 갈 필요가 없이 지난 번 website의 response를 cache에 저장해 두었다가 바로 회신할 수 있을 것이다. 이것이 아래 그림의 위 세 번의 request/response 쌍의 상황이다. 노란색 user의 request는 website까지 가서 response를 받아오지만, cache server는 이를 저장해 두었다가 파란색, 보라색 user의 request를 바로 회신해준다.
참고 링크 및 이미지 출처: https://portswigger.net/research/web-cache-entanglement
그러면 캐싱은 어떻게 할까?
동일한 request라는 걸 인지할 수 있을 만큼의 정보를 request에서 추출해내서 key로 삼는다 이걸 cache key라고 한다.
그리고 해당 request에 대해 website가 회신한 response 전체를 value로 저장하는 것이다. 이제 request에서 추출한 cache key로 저장된 value가 이미 있으면(== 캐싱되어 있으면) website에 요청할 필요 없이 바로 response를 해줄 수 있다.
request |
GET /research?x=1 HTTP/1.1 Host: portswigger.net X-Forwarded-Host: attacker.net User-Agent: Firefox/57.0 Cookie: language=en; |
request에서 추출한 cache key | Cache key: https|GET|portswigger.net|/research?x=1 |
공격자의 cache poisoning
request에서 cache key로 사용하지 않는 녀석들을 unkeyed components라고 한다. 위 예에서 X-Forwarded-Host, User-Agent, Cookie 같은 것들이다. 공격자는 이러한 곳에 악성코드를 심어서 website가 response 할때에 "아무 생각없이" 포함해서 회신하게 만든다. 이렇게 "독"을 머금은 cache를 중간의 proxy server나 infrastructure가 가지고 있다가 정상적인 request에 대해서 회신을 해줘버리는 거다. 그림의 아래 세 번의 request/response 상황이다.
TL;DR. websocket의 masking은 cache poisoning을 어떻게 막는가?
매번 클라이언트가 메시지를 보낼때마다 랜덤하게 생성한 masking key로 메시지를 XOR 해준다. 이렇게 해주면 websocket message를 HTTP request(또는 response)로 인식해버리는 똑똑하지 못한 proxy server, infrastructure라도 똑같은 websocket message를 모두 다른 HTTP request로(== 다른 cache key)로 인식해버려서 캐싱하지 않는 것이다. 복잡하고 어려운 암호화가 필요없는 이유도 여기에 있다.
Websocket protocol에서의 masking
이전 포스팅 https://jusths.tistory.com/249 에서도 소개한 websocket protocol에서의 maksing을 복습해보자
1. MASK bit을 1로 설정해서 masking 한다는 것을 알리고
2. Masking-Key 32bit를 추가해둔다. 이때 전송하는 Payload Data를 Masking-Key로 XOR 해준다.
3. 그러면 서버는 Payload Data를 Masking-Key로 XOR해줘서 원본을 복구해주면 되는 것이다.
gorilla/websocket에서는 어떻게 구현되어 있지?
gorilla/websocket GitHub: https://github.com/gorilla/websocket
websocket protocol을 구현한 Golang package인 gorilla/websocket에서는 이를 어떻게 구현해두었는지 살펴보았다.
클라이언트에서 마스킹을 해서 쓰기 - 무조건 마스킹한다.
앞서 살펴보았듯 cache poisoning을 막으려면 캐시 서버가 같은 request라고만 생각지 않으면 된다. 그래서 클라이언트가 서버로 보내는 메시지만 masking 을 해주면 된다. gorilla/websocket은 클라이언트에서 보내는 메시지만 무조건 masking 하도록 구현이 되어있다.
Websocket으로 연결이 된 다음 메시지를 전달하려면 WriteMessage 메서드를 사용하면 되는데, 클라이언트는 NextWriter 메서드로 생성한 *messageWriter의 Write 메서드를 사용한다
// WriteMessage is a helper method for getting a writer using NextWriter,
// writing the message and closing the writer.
func (c *Conn) WriteMessage(messageType int, data []byte) error {
if c.isServer && (c.newCompressionWriter == nil || !c.enableWriteCompression) {
// Fast path with no allocations and single frame.
var mw messageWriter
if err := c.beginMessage(&mw, messageType); err != nil {
return err
}
n := copy(c.writeBuf[mw.pos:], data)
mw.pos += n
data = data[n:]
return mw.flushFrame(true, data)
}
w, err := c.NextWriter(messageType)
if err != nil {
return err
}
if _, err = w.Write(data); err != nil {
return err
}
return w.Close()
}
Write 메서드에서는 다시 flushFrame 메서드를 직접, 또는 ncopy 메서드를 거쳐서 사용하는데 바로 flushFrame 메서드에서 masking 작업을 한다
https://github.com/gorilla/websocket/blob/e8629af678b7fe13f35dff5e197de93b4148a909/conn.go#L650
func (w *messageWriter) Write(p []byte) (int, error) {
if w.err != nil {
return 0, w.err
}
if len(p) > 2*len(w.c.writeBuf) && w.c.isServer {
// Don't buffer large messages.
err := w.flushFrame(false, p)
if err != nil {
return 0, err
}
return len(p), nil
}
nn := len(p)
for len(p) > 0 {
n, err := w.ncopy(len(p))
if err != nil {
return 0, err
}
copy(w.c.writeBuf[w.pos:], p[:n])
w.pos += n
p = p[n:]
}
return nn, nil
}
flushFrame 메서드에서 websocket protocol에 맞게 프레임을 만들어내는 것이다. 마스크 관련한 부분만 가져와 본다
서버쪽 연결이 아니면, 즉, 클라이언트쪽에서 보내는 메시지라면 maskBit을 적용하는 것이다. 매 번 flushFrame 메서드를 호출할 때 마다 newMaskKey로 key를 생성하는 것도 볼 수 있다.
// Frame header byte 1 bits from Section 5.2 of RFC 6455
const maskBit = 1 << 7
// flushFrame writes buffered data and extra as a frame to the network. The
// final argument indicates that this is the last frame in the message.
func (w *messageWriter) flushFrame(final bool, extra []byte) error {
// omit codes
b1 := byte(0)
if !c.isServer {
b1 |= maskBit
}
// omit codes
if !c.isServer {
key := newMaskKey()
copy(c.writeBuf[maxFrameHeaderSize-4:], key[:])
maskBytes(key, 0, c.writeBuf[maxFrameHeaderSize:w.pos])
if len(extra) > 0 {
return w.endMessage(c.writeFatal(errors.New("websocket: internal error, extra used in client mode")))
}
}
// omit codes
}
실제 masking을 해주는 함수는 maskBytes이다. 패키지에서는 safe모드와, 성능을 향상시킨 unsafe 모드를 제공한다. 링크를 아래에 공유한다
safe mode: https://github.com/gorilla/websocket/blob/e8629af678b7fe13f35dff5e197de93b4148a909/mask_safe.go#L9
unsafe mode: https://github.com/gorilla/websocket/blob/e8629af678b7fe13f35dff5e197de93b4148a909/mask.go#L13
서버에서 읽기
여기까지 이해하면 어떻게 처리할지는 충분히 예측이 가능하다.
1. ReadMessage 메서드의 주체가 서버인지를 확인해서
2. 서버라면 받은 프레임의 MASK bit를 확인하고, Masking-Key까지 알아낸다
3. 그래서 maskBytes 함수로 Payload Data를 다시 한 번 XOR 해준다. XOR을 두 번 해주면 원래 data가 된다.
코드를 보자. ReadMessage 메서드는 NextReader 메서드를 호출하는데 결국 실제 처리는 advanceFrame 메서드가 한다.
아래에 핵심 부분만 추려보았다.
https://github.com/gorilla/websocket/blob/e8629af678b7fe13f35dff5e197de93b4148a909/conn.go#L787
func (c *Conn) advanceFrame() (int, error) {
// omit codes
// 4. Handle frame masking.
if mask != c.isServer {
return noFrame, c.handleProtocolError("incorrect mask flag")
}
if mask {
c.readMaskPos = 0
p, err := c.read(len(c.readMaskKey))
if err != nil {
return noFrame, err
}
copy(c.readMaskKey[:], p)
}
// omit codes
// 6. Read control frame payload.
var payload []byte
if c.readRemaining > 0 {
payload, err = c.read(int(c.readRemaining))
c.setReadRemaining(0)
if err != nil {
return noFrame, err
}
if c.isServer {
maskBytes(c.readMaskKey, 0, payload)
}
}
// omit codes
}