Write-up

[CSAW 2020] PwnVoltex

ch4rli3kop 2020. 10. 14. 18:00
반응형

[CSAW 2020] PwnVoltex

[SUMMARY] Blind SQLi

Overview

리듬 게임 문제이며 서버가 제공되었음.

Target

https://github.com/Drewol/unnamed-sdvx-clone

Readme

Hello!

We are going to have some fun and play my fav game: https://github.com/Drewol/unnamed-sdvx-clone

You can grab it prebuilt from here:

Windows: https://drewol.me/Downloads/Game.zip
Linux (needs github login): https://github.com/Drewol/unnamed-sdvx-clone/suites/1128119397/artifacts/16053517

You can then put the `flagsong` directory into the `songs` directory of USC to play it

Now for the actual challenge:
You have to retrieve the real flag from `./flagsong/flag.ksh` on my computer
(Your copy doesn't have the real flag in it).


My bot (who has the real flag song) will play multiplayer with you if you like.
It will be using https://github.com/itszn/usc-multiplayer-server
on 34.234.204.29 port 39079 (this can be set in the USC settings under online)

The multiplayer sever will restart every 15 min

NOTE: Some functionality of the bot has been simplified for this challenge
but the intended bug left intact. If you think you have a bug that won't
reproduce on the real bot, message me (itszn) and I can test the full, unaltered,
client for you. The client the bot is using should be feature equivalent to
git commit 649b444485db4b1f6dfa0e6adab90dd242764394

Also in case you need it, my computer is running Windows 10
(but you probably don't need to know this...)

ALSO if you are actually good at SDVX, let me know and we can play
some real matches :)

간단하게 요약하면, 해당 게임의 취약점을 찾고 출제자의 서버에 접속하여 봇의 노래 이름을 읽어오면 된다.

Analysis

각 노래는 songs 아래에 디렉토리 별로 존재하며, jpg, ksh, ogg 파일로 이루어져 게임에 사용된다.

Attack Surface

게임 진행에 위 세가지의 파일이 사용되는 만큼, 각 파일들이 공격벡터로 사용될 것이라 추측했고, 그 중 가장 유력한 공격벡터는 다음의 ksh 파일이라고 생각했다. ksh 파일은 다음과 같으며, 플레이할 노래의 title, artist, jacket 사진, song 파일, 최대 점수, 막대 도트들에 대한 정보가 저장된다.

게임 코드가 많았기도 하고, 제한된 시간 내에 다 분석할 자신이 없었기 때문에 일단 될만한 공격들을 다 때려보았다.

최초로 시도해보았던 것은 Overflow 였고, ksh 파일 내에 존재하는 모든 항목들에 대해서 값을 많이 넣어보았으나, 별 소득이 없었고 format string bug도 마찬가지였다.

가만 생각해보다가 내 사고회로가 다음 두 가지를 생각해냈다.

  1. 오픈소스 게임이기 때문에, 깃 로그에 취약점 패치 로그가 있지 않을까?

  2. 코드 상의 취약점이 아니라 로직 버그나 프로토콜 취약점이 아닐까?

문제의 커밋 로그 이후로, 딱히 이 문제에서 사용할만한 취약점이나 버그와 관련된 커밋은 없는거 같아서 바로 2번으로 넘어갔다.

출제자의 서버에 접속하면 기본적으로 로비에 접속하게 되고, 그 상태에서 Room을 생성하면 봇과 함께 게임을 진행할 수 있다. 약간의 시간 후에 봇이 ready 상태가 되는데, 우선 이 과정의 패킷을 살펴보았다.

먼저 유저 정보를 서버로 전송하여 서버로부터 id를 발급받고, Room을 생성할 수 있다. Room을 생성하면 봇이 접속하게 되고, 봇은 호스트가 선택한 노래정보를 찾는다. 위의 패킷에서는 호스트가 노래를 선택해서 해당 노래의 정보를 봇에게 보내면, 봇은 호스트의 노래 정보를 찾아 Ready를 결정하는 것을 확인할 수 있다.

게임에서 사용하는 프로토콜 정보는 게임 사이트가면 정리되어 있는게 있으니, 참고하면 좋을 듯하다.

Protocol

봇이 노래를 찾았을 때와 찾지 못했을 때의 차이는 다음과 같다.

봇이 노래를 찾았을 때

Ready 상태가 되는 걸 확인할 수 있다.

봇이 노래를 못 찾았을 때

봇이 갖지 않은 노래를 호스트가 선택했을 때의 패킷은 위와 같다.

.. 대회동안에 딱 여기까지 했다.

Code

호스트가 노래를 선택한 뒤, 게스트에서 해당 노래를 선택하는 부분은 다음과 같다. 게스트는 호스트가 보낸 패킷에서 차트 해쉬 값 및 노래 이름을 받아, 로컬 저장소에서 해당 노래가 존재하는지 검색한다.

m_handleSongChange

chart_hash와 song 이름을 인자로 하여 m_getChartByHash()를 호출한다.

bool MultiplayerScreen::m_handleSongChange(nlohmann::json& packet)
{
// Case for no song selected yet
if (packet["song"].is_null())
return true;

const String& chart_hash = packet.value("chart_hash", "");
const String& audio_hash = packet.value("audio_hash", "");
const String& song = packet.value("song", "");

// Fallback case for no hash provided from the server
if (m_hasSelectedMap && chart_hash.length() == 0 && song == m_selectedMapShortPath)
return true;

// Case for same song as before
if (m_hasSelectedMap && chart_hash == m_selectedMapHash)
return true;

// Clear jacket variable to force image reload
lua_pushinteger(m_lua, 0);
lua_setglobal(m_lua, "jacket");


// Grab new song
uint32 diff_ind = packet["diff"];
ChartIndex* newChart = m_getChartByHash(chart_hash, song, &diff_ind, packet["level"]);

if (newChart == nullptr)
{
// If we don't find it, then update lua state
m_hasSelectedMap = false;
m_clearLuaMap();

// Tell the server we don't have the map
// TODO(itszn) send less frequently?
nlohmann::json packet;
packet["topic"] = "user.nomap";
m_tcp.SendJSON(packet);

return true;
}

m_selfPicked = false;
m_updateSelectedMap(newChart->folderId, diff_ind, false);

m_selectedMapShortPath = song;
m_selectedMapHash = chart_hash;

return true;
}

m_getChartByHash

hash 값이 명시되어 있지 않다면, song 이름으로 차트를 찾는다. (m_getChartByShortPath() 호출)

// Get a song for given audio hash and level
// If we find a match find a matching hash but not level, just go with any level
ChartIndex* MultiplayerScreen::m_getChartByHash(const String& hash, const String& path, uint32* diffIndex, int32 level)
{
// Fallback on an empty hash
if (hash.length() == 0)
return m_getChartByShortPath(path, diffIndex, level, true);

Logf("[Multiplayer] looking up song hash '%s' level %u", Logger::Severity::Info, *hash, level);
for (auto folder : m_mapDatabase->FindFoldersByHash(hash))
{
ChartIndex* newChart = NULL;

for (size_t ind = 0; ind < folder.second->charts.size(); ind++)
{
ChartIndex* chart = folder.second->charts[ind];
if (chart->level == level)
{
// We found a matching level for this hash, good to go
newChart = chart;
*diffIndex = ind;
}
}

// We didn't find the exact level, but we should still use this map anyway
if (newChart == NULL)
{
assert(folder.second->charts.size() > 0);
*diffIndex = 0;
newChart = folder.second->charts[0];
}

if (newChart != NULL)
{
Logf("[Multiplayer] Found: diff_id=%d mapid=%d index=%u path=%s", Logger::Severity::Info, newChart->id, newChart->folderId, *diffIndex, newChart->path.c_str());
return newChart;
}
}
Log("[Multiplayer] Could not find song by hash, falling back to foldername", Logger::Severity::Warning);
return m_getChartByShortPath(path, diffIndex, level, true);
}

m_getChartByShortPath

FindFoldersByPath()를 호출하여 song 이름으로 차트를 검색한다.

// Get a map from a given "short" path, and level
// The selected index will be written to diffIndex
// diffIndex can be used as a hint to which song to pick
ChartIndex* MultiplayerScreen::m_getChartByShortPath(const String& path, uint32* diffIndex, int32 level, bool useHint)
{
Logf("[Multiplayer] looking up song '%s' level %u difficulty index hint %u", Logger::Severity::Info, path.c_str(), level, *diffIndex);

for (auto folder : m_mapDatabase->FindFoldersByPath(path))
{
ChartIndex* newChart = NULL;

for (size_t ind = 0; ind < folder.second->charts.size(); ind++)
{
ChartIndex* chart = folder.second->charts[ind];

if (chart->level == level)
{
// First we try to get exact song (kinda) by matching index hint to level
if (useHint && *diffIndex != ind)
break;

// If we already searched the songs just take any level that matches
newChart = chart;
*diffIndex = ind;
break;
}
}

// If we didn't find any matches and we are on our last try, just pick anything
if (newChart == NULL && !useHint)
{
assert(folder.second->charts.size() > 0);
*diffIndex = 0;
newChart = folder.second->charts[0];
}


if (newChart != NULL)
{
Logf("[Multiplayer] Found: diff_id=%d mapid=%d index=%u path=%s", Logger::Severity::Info, newChart->id, newChart->folderId, *diffIndex, newChart->path.c_str());
return newChart;
}
}

// Search one more time, but ignore the hint this time
if (useHint)
{
*diffIndex = 0;
return m_getChartByShortPath(path, diffIndex, level, false);
}

Log("[Multiplayer] Could not find song", Logger::Severity::Warning);
return nullptr;
}

FindFoldersByPath

song 이름을 이용하여 SQL query 문으로 차트를 검색한다.

    Map<int32, FolderIndex*> FindFoldersByPath(const String& searchString)
{
String stmt = "SELECT DISTINCT folderId FROM Charts WHERE path LIKE \"%" + searchString + "%\"";

Map<int32, FolderIndex*> res;
DBStatement search = m_database.Query(stmt);
while(search.StepRow())
{
int32 id = search.IntColumn(0);
FolderIndex** folder = m_folders.Find(id);
if(folder)
{
res.Add(id, *folder);
}
}

return res;
}

취약점은 여기 존재한다. searchString에 대한 검증이 없어, SQL injection이 발생한다.

Exploit

song 이름에서 발생하는 SQL injection과 해당 결과에 따른 Ready의 유무로 query 문의 결과 값을 알 수 있다. 이를 이용하여 blind sqli 공격으로 봇에 존재하는 song 이름을 알아낼 수 있다.

...


Reference

https://ptr-yudai.hatenablog.com/entry/2020/09/14/181939#Pwn-250pts-pwnvoltex-20-solves

반응형

'Write-up' 카테고리의 다른 글

TFCCTF 2022 Write-up  (0) 2022.08.02
[HEVD] StackOverflow  (0) 2020.10.14
[GoogleCTF2020] Android writeup  (2) 2020.08.31
[MidnightSun CTF 2020] StarCraft writeup  (0) 2020.07.26
Uncrackable 2  (0) 2020.07.15